package cloud.nstart.terravault.ordinis.cuod;

import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
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.definition.DictionaryDefinitionCreated;
import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionUpdated;
import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordCreated;
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

/**
 * При старте сервиса проверяет/создаёт справочники из ЦУОД bundle.
 *
 * <p>Идемпотентен: если справочник уже существует — сравнивает schema_version,
 * при отличии и {@code updateExistingSchema=true} обновляет (с DefinitionUpdated
 * outbox event). В противном случае не трогает.
 */
@Service
@Configuration
@EnableConfigurationProperties(CuodBundleProperties.class)
@ConditionalOnProperty(name = "ordinis.bundle.cuod.auto-import", havingValue = "true", matchIfMissing = false)
public class CuodBundleImporter {

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

  private final CuodBundleProperties props;
  private final DictionaryDefinitionRepository repository;
  private final DictionaryRecordRepository recordRepository;
  private final OutboxRecorder outboxRecorder;
  private final ObjectMapper objectMapper;
  private final PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();

  public CuodBundleImporter(
      CuodBundleProperties props,
      DictionaryDefinitionRepository repository,
      DictionaryRecordRepository recordRepository,
      OutboxRecorder outboxRecorder,
      ObjectMapper objectMapper) {
    this.props = props;
    this.repository = repository;
    this.recordRepository = recordRepository;
    this.outboxRecorder = outboxRecorder;
    this.objectMapper = objectMapper;
  }

  @Transactional
  public void importBundle() throws Exception {
    Resource manifestResource = resolver.getResource(props.resourceRoot() + "manifest.json");
    if (!manifestResource.exists()) {
      log.warn("ЦУОД bundle manifest не найден по пути {}, пропускаем auto-import", props.resourceRoot());
      return;
    }

    BundleManifest manifest;
    try (var in = manifestResource.getInputStream()) {
      manifest = objectMapper.readValue(in, BundleManifest.class);
    }
    log.info("Импорт ЦУОД bundle '{}' v{}: {} справочников",
        manifest.bundle(), manifest.version(), manifest.dictionaries().size());

    for (BundleManifest.DictionaryEntry entry : manifest.dictionaries()) {
      try {
        importEntry(manifest, entry);
      } catch (Exception e) {
        log.error("Ошибка импорта справочника '{}': {}", entry.name(), e.getMessage(), e);
      }
    }

    log.info("Импорт ЦУОД bundle '{}' завершён", manifest.bundle());
  }

  private void importEntry(BundleManifest manifest, BundleManifest.DictionaryEntry entry) throws Exception {
    Resource schemaResource = resolver.getResource(props.resourceRoot() + entry.schemaResource());
    JsonNode schema;
    try (var in = schemaResource.getInputStream()) {
      schema = objectMapper.readTree(in);
    }

    List<String> entryLocales = entry.supportedLocales() == null || entry.supportedLocales().isEmpty()
        ? manifest.supportedLocales()
        : entry.supportedLocales();
    String entryDefault = entry.defaultLocale() == null ? manifest.defaultLocale() : entry.defaultLocale();

    Optional<DictionaryDefinition> existing = repository.findByName(entry.name());
    if (existing.isPresent()) {
      DictionaryDefinition def = existing.get();
      if (props.updateExistingSchema() && !entry.schemaVersion().equals(def.getSchemaVersion())) {
        JsonNode prevSchema = def.getSchemaJson();
        def.setSchemaJson(schema);
        def.setSchemaVersion(entry.schemaVersion());
        def.setDescription(entry.description());
        def.setDisplayName(entry.displayName());
        def.setSupportedLocales(entryLocales.toArray(String[]::new));
        def.setDefaultLocale(entryDefault);
        // Approval Workflow v2: bundle-driven opt-in. Только если entry задаёт
        // явное значение — иначе оставляем текущее (не сбрасываем UI-toggle'и).
        // Honor только когда props.honorApprovalPolicy=true (Phase 3 soak gate).
        if (props.honorApprovalPolicy() && entry.approvalRequired() != null) {
          def.setApprovalRequired(entry.approvalRequired());
        }
        if (props.honorApprovalPolicy() && entry.approvalMinRole() != null) {
          def.setApprovalMinRole(entry.approvalMinRole());
        }
        DictionaryDefinition saved = repository.save(def);

        var event = new DictionaryDefinitionUpdated(
            saved.getId(), saved.getName(), saved.getDisplayName(), saved.getDescription(),
            toEventScope(saved.getScope()), saved.getSchemaJson(), prevSchema,
            saved.getSchemaVersion(), saved.getBundle(),
            List.of(saved.getSupportedLocales()), saved.getDefaultLocale(),
            saved.getUpdatedAt(), saved.getUpdatedBy());
        outboxRecorder.record(
            "DefinitionUpdated", "DictionaryDefinition", saved.getId().toString(),
            saved.getName(), saved.getScope(), manifest.bundle(),
            "definition:" + saved.getName(),
            EventEnvelope.of(saved.getName(), toEventScope(saved.getScope()), manifest.bundle(), event));
        log.info("Справочник '{}' обновлён до v{}", saved.getName(), saved.getSchemaVersion());
        def = saved;
      } else {
        log.debug("Справочник '{}' v{} актуален, schema не трогаем", entry.name(), def.getSchemaVersion());
        // Approval Workflow v2 soak: даже если schema_version неизменилась,
        // bundle-driven opt-in требует, чтобы Boolean approvalRequired в
        // manifest применился к существующему dict (Phase 3 — flip без bumping
        // schema). Apply ТОЛЬКО при изменении — иначе spam UPDATE'ов. Honor
        // только когда props.honorApprovalPolicy=true (env-gated).
        if (props.honorApprovalPolicy()) {
          boolean changed = false;
          if (entry.approvalRequired() != null
              && entry.approvalRequired() != def.isApprovalRequired()) {
            def.setApprovalRequired(entry.approvalRequired());
            changed = true;
          }
          if (entry.approvalMinRole() != null
              && !java.util.Objects.equals(entry.approvalMinRole(), def.getApprovalMinRole())) {
            def.setApprovalMinRole(entry.approvalMinRole());
            changed = true;
          }
          if (changed) {
            def = repository.save(def);
            log.info("Справочник '{}' approval policy обновлён: required={} role={}",
                def.getName(), def.isApprovalRequired(), def.getApprovalMinRole());
          }
        }
      }
      // Records seed запускаем всегда — они идемпотентны (skip on existing businessKey).
      importRecords(manifest, entry, def);
      return;
    }

    DictionaryDefinition fresh = new DictionaryDefinition(
        UUID.randomUUID(), entry.name(), entry.scope(), schema);
    fresh.setDisplayName(entry.displayName());
    fresh.setDescription(entry.description());
    fresh.setSchemaVersion(entry.schemaVersion());
    fresh.setBundle(manifest.bundle());
    fresh.setSupportedLocales(entryLocales.toArray(String[]::new));
    fresh.setDefaultLocale(entryDefault);
    // Approval Workflow v2: per-dict opt-in через manifest (Phase 3 soak).
    // Honor только когда props.honorApprovalPolicy=true — staging opts in,
    // prod игнорирует пока явно не включит.
    if (props.honorApprovalPolicy() && entry.approvalRequired() != null) {
      fresh.setApprovalRequired(entry.approvalRequired());
    }
    if (props.honorApprovalPolicy() && entry.approvalMinRole() != null) {
      fresh.setApprovalMinRole(entry.approvalMinRole());
    }
    DictionaryDefinition saved = repository.save(fresh);

    var event = new DictionaryDefinitionCreated(
        saved.getId(), saved.getName(), saved.getDisplayName(), saved.getDescription(),
        toEventScope(saved.getScope()), saved.getSchemaJson(),
        saved.getSchemaVersion(), saved.getBundle(),
        List.of(saved.getSupportedLocales()), saved.getDefaultLocale(),
        saved.getCreatedAt(), saved.getCreatedBy());
    outboxRecorder.record(
        "DefinitionCreated", "DictionaryDefinition", saved.getId().toString(),
        saved.getName(), saved.getScope(), manifest.bundle(),
        "definition:" + saved.getName(),
        EventEnvelope.of(saved.getName(), toEventScope(saved.getScope()), manifest.bundle(), event));
    log.info("Справочник '{}' создан (scope={}, locales={}, default={})",
        saved.getName(), saved.getScope(), entryLocales, entryDefault);

    importRecords(manifest, entry, saved);
  }

  /**
   * Подгружает seed records из {@code records/<name>.records.json} (если файл задан в
   * manifest через {@code recordsResource} или существует по конвенции). Идемпотентно:
   * при повторном вызове не создаёт дубликатов — пропускает существующие businessKey.
   */
  private void importRecords(BundleManifest manifest, BundleManifest.DictionaryEntry entry,
                             DictionaryDefinition definition) {
    String recordsPath = entry.recordsResource() != null
        ? entry.recordsResource()
        : "records/" + entry.name() + ".records.json";
    Resource recordsResource = resolver.getResource(props.resourceRoot() + recordsPath);
    if (!recordsResource.exists()) {
      log.debug("Records file для '{}' не найден ({}), пропускаем seed", entry.name(), recordsPath);
      return;
    }

    JsonNode recordsArray;
    try (var in = recordsResource.getInputStream()) {
      recordsArray = objectMapper.readTree(in);
    } catch (Exception e) {
      log.warn("Не удалось прочитать records '{}': {}", recordsPath, e.getMessage());
      return;
    }
    if (!recordsArray.isArray()) {
      log.warn("Records '{}' не массив, пропускаем", recordsPath);
      return;
    }

    int created = 0;
    int skipped = 0;
    OffsetDateTime now = OffsetDateTime.now();
    for (JsonNode rec : recordsArray) {
      String businessKey = rec.path("businessKey").asText(null);
      JsonNode data = rec.path("data");
      if (businessKey == null || businessKey.isBlank() || data.isMissingNode()) {
        log.warn("Пропуск некорректной записи в '{}': missing businessKey/data", recordsPath);
        continue;
      }
      List<DictionaryRecord> existing = recordRepository
          .findByDictionaryIdAndBusinessKeyOrderByValidFromDesc(definition.getId(), businessKey);
      if (!existing.isEmpty()) {
        skipped++;
        continue;
      }
      DictionaryRecord record = new DictionaryRecord(
          UUID.randomUUID(), definition.getId(), businessKey, data, definition.getScope(), now);
      // Auto-derive POINT geometry из data.lat/lon — match service behaviour.
      // Без этого AOI spatial filter возвращает 0 records даже когда у dict
      // есть записи с координатами.
      org.locationtech.jts.geom.Geometry derived = deriveLatLonGeometry(data);
      if (derived != null) record.setGeometry(derived);
      DictionaryRecord savedRecord = recordRepository.save(record);

      var recEvent = new DictionaryRecordCreated(
          savedRecord.getId(), savedRecord.getBusinessKey(), savedRecord.getData(), null,
          savedRecord.getValidFrom(), savedRecord.getValidTo(),
          savedRecord.getCreatedAt(), savedRecord.getCreatedBy());
      outboxRecorder.record(
          "RecordCreated", "DictionaryRecord", savedRecord.getId().toString(),
          definition.getName(), savedRecord.getDataScope(), manifest.bundle(),
          "record:" + definition.getName() + ":" + savedRecord.getBusinessKey(),
          EventEnvelope.of(definition.getName(), toEventScope(savedRecord.getDataScope()),
              manifest.bundle(), recEvent));
      created++;
    }
    log.info("Records для '{}': создано {}, пропущено (уже есть) {}",
        entry.name(), created, skipped);
  }

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

  /**
   * Auto-derive POINT(lon lat) geometry из data.lat/lon. Mirror'ит logic в
   * DictionaryRecordService.resolveGeometry — bundle importer bypass'ит
   * service, поэтому ту же конвенцию реализуем тут отдельно. Match keys:
   * lat|latitude, lon|longitude|lng. Validate ranges.
   */
  private static org.locationtech.jts.geom.Geometry deriveLatLonGeometry(JsonNode data) {
    if (data == null || data.isMissingNode()) return null;
    Double lat = pickNumber(data, "lat", "latitude");
    Double lon = pickNumber(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;
    try {
      org.locationtech.jts.geom.Geometry g = new org.locationtech.jts.io.WKTReader()
          .read("POINT(" + lon + " " + lat + ")");
      g.setSRID(4326);
      return g;
    } catch (org.locationtech.jts.io.ParseException e) {
      return null;
    }
  }

  private static Double pickNumber(JsonNode data, String... keys) {
    for (String k : keys) {
      JsonNode n = data.get(k);
      if (n != null && n.isNumber()) return n.doubleValue();
    }
    return null;
  }
}
