package at.procon.eventhub.persistence;

import at.procon.eventhub.dto.DriverCardRefDto;
import at.procon.eventhub.dto.DriverRefDto;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.dto.SourcePackageRefDto;
import at.procon.eventhub.dto.VehicleRefDto;
import at.procon.eventhub.dto.VehicleRegistrationRefDto;
import at.procon.eventhub.persistence.VehicleIdentityRepository.ResolvedVehicleReference;
import at.procon.eventhub.persistence.VehicleIdentityRepository.ResolvedVehicleReferenceResolution;
import at.procon.eventhub.service.EventAcquisitionRecordKeyService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.jdbc.core.BatchPreparedStatementSetter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
public class EventRepository {

    private final JdbcTemplate jdbcTemplate;
    private final ObjectMapper objectMapper;
    private final EventAcquisitionRecordKeyService recordKeyService;
    private final SourceMasterDataRepository sourceMasterDataRepository;
    private final DriverIdentityRepository driverIdentityRepository;
    private final VehicleIdentityRepository vehicleIdentityRepository;

    public EventRepository(
            JdbcTemplate jdbcTemplate,
            ObjectMapper objectMapper,
            EventAcquisitionRecordKeyService recordKeyService,
            SourceMasterDataRepository sourceMasterDataRepository,
            DriverIdentityRepository driverIdentityRepository,
            VehicleIdentityRepository vehicleIdentityRepository
    ) {
        this.jdbcTemplate = jdbcTemplate;
        this.objectMapper = objectMapper;
        this.recordKeyService = recordKeyService;
        this.sourceMasterDataRepository = sourceMasterDataRepository;
        this.driverIdentityRepository = driverIdentityRepository;
        this.vehicleIdentityRepository = vehicleIdentityRepository;
    }

    /**
     * Persists normalized events and resolves master-data references on the fly.
     * The source-record hash is unique and provides source-level import idempotency.
     */
    public int batchInsert(UUID packageId, String tenantKey, int eventSourceId, List<EventHubEventDto> events) {
        Map<String, UUID> entityIdCache = new HashMap<>();
        Map<String, DriverIdentityRepository.ResolvedDriverReference> driverRefCache = new HashMap<>();
        Map<String, List<VehicleRefCacheEntry>> vehicleRefCache = new HashMap<>();
        List<ResolvedEventImportRow> rows = new ArrayList<>(events.size());

        for (EventHubEventDto event : events) {
            ResolvedEntityRefs refs = resolveEntityRefs(tenantKey, eventSourceId, event, entityIdCache, driverRefCache, vehicleRefCache);
            rows.add(resolveEventImportRow(packageId, eventSourceId, event, refs));
        }

        createEventImportStage();
        stageEvents(rows);
        int insertedCount = insertStagedEvents();
        Map<String, InsertedEventRow> eventRowsBySourceRecord = resolveStagedEventRows();
        batchUpsertEventDetails(rows, eventRowsBySourceRecord);
        return insertedCount;
    }

    private ResolvedEventImportRow resolveEventImportRow(
            UUID packageId,
            int eventSourceId,
            EventHubEventDto event,
            ResolvedEntityRefs refs
    ) {
        UUID requestedEventId = event.eventId() == null ? UUID.randomUUID() : event.eventId();
        OffsetDateTime receivedHubAt = event.receivedHubAt() == null ? OffsetDateTime.now() : event.receivedHubAt();
        String sourceRecordKeyHash = recordKeyService.buildSourceRecordKeyHash(event, eventSourceId);
        Double longitude = event.position() == null ? null : event.position().longitude().doubleValue();
        Double latitude = event.position() == null ? null : event.position().latitude().doubleValue();
        String sourcePackageId = event.sourcePackageRef() == null ? null : event.sourcePackageRef().sourcePackageId();

        return new ResolvedEventImportRow(
                sourceRecordKeyHash,
                requestedEventId,
                packageId,
                eventSourceId,
                event.externalSourceEventId(),
                refs.driverId(),
                refs.driverCardId(),
                refs.vehicleId(),
                refs.vehicleRegistrationId(),
                sourcePackageId,
                refs.sourcePackageEntityId(),
                event.occurredAt(),
                event.receivedPartnerAt(),
                receivedHubAt,
                event.eventDomain().name(),
                event.eventType().name(),
                event.lifecycle().name(),
                event.odometerM(),
                longitude,
                latitude,
                toJson(event.payload()),
                event.manualEntry(),
                recordKeyService.buildEventSignatureHash(event),
                event.eventDetails() == null ? null : event.eventDetails().type(),
                event.eventDetails() == null ? null : toJson(event.eventDetails().attributes())
        );
    }

    private void createEventImportStage() {
        jdbcTemplate.execute(
                """
                create temporary table if not exists eventhub_event_import_stage (
                    row_no integer not null,
                    source_record_key_hash text not null,
                    requested_event_id uuid not null,
                    data_package_id uuid not null,
                    event_source_id integer not null,
                    external_source_event_id text not null,
                    driver_id uuid,
                    driver_card_id uuid,
                    vehicle_id uuid,
                    vehicle_registration_id uuid,
                    source_package_id text,
                    source_package_entity_id uuid,
                    occurred_at timestamptz not null,
                    received_partner_at timestamptz,
                    received_hub_at timestamptz not null,
                    event_domain text not null,
                    event_type text not null,
                    lifecycle text not null,
                    odometer_m bigint,
                    longitude double precision,
                    latitude double precision,
                    payload jsonb not null,
                    manual_entry boolean not null,
                    event_signature_hash text
                ) on commit drop
                """
        );
        jdbcTemplate.execute("truncate table eventhub_event_import_stage");
    }

    private void stageEvents(List<ResolvedEventImportRow> rows) {
        jdbcTemplate.batchUpdate(
                """
                insert into eventhub_event_import_stage(
                    row_no, source_record_key_hash, requested_event_id, data_package_id, event_source_id,
                    external_source_event_id, driver_id, driver_card_id, vehicle_id, vehicle_registration_id,
                    source_package_id, source_package_entity_id, occurred_at, received_partner_at, received_hub_at,
                    event_domain, event_type, lifecycle, odometer_m, longitude, latitude,
                    payload, manual_entry, event_signature_hash
                ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?, ?)
                """,
                new BatchPreparedStatementSetter() {
                    @Override
                    public void setValues(PreparedStatement ps, int i) throws SQLException {
                        ResolvedEventImportRow row = rows.get(i);
                        ps.setInt(1, i);
                        ps.setString(2, row.sourceRecordKeyHash());
                        ps.setObject(3, row.requestedEventId());
                        ps.setObject(4, row.packageId());
                        ps.setInt(5, row.eventSourceId());
                        ps.setString(6, row.externalSourceEventId());
                        ps.setObject(7, row.driverId());
                        ps.setObject(8, row.driverCardId());
                        ps.setObject(9, row.vehicleId());
                        ps.setObject(10, row.vehicleRegistrationId());
                        ps.setString(11, row.sourcePackageId());
                        ps.setObject(12, row.sourcePackageEntityId());
                        ps.setObject(13, row.occurredAt());
                        ps.setObject(14, row.receivedPartnerAt());
                        ps.setObject(15, row.receivedHubAt());
                        ps.setString(16, row.eventDomain());
                        ps.setString(17, row.eventType());
                        ps.setString(18, row.lifecycle());
                        ps.setObject(19, row.odometerM());
                        ps.setObject(20, row.longitude());
                        ps.setObject(21, row.latitude());
                        ps.setString(22, row.payloadJson());
                        ps.setBoolean(23, row.manualEntry());
                        ps.setString(24, row.eventSignatureHash());
                    }

                    @Override
                    public int getBatchSize() {
                        return rows.size();
                    }
                }
        );
    }

    private int insertStagedEvents() {
        Long insertedCount = jdbcTemplate.queryForObject(
                """
                with stage_one as (
                    select distinct on (source_record_key_hash) *
                    from eventhub_event_import_stage
                    order by source_record_key_hash, row_no
                ),
                reserved_source_record as (
                    insert into eventhub.event_source_record(
                        source_record_key_hash, event_occurred_at, event_id
                    )
                    select source_record_key_hash, occurred_at, requested_event_id
                    from stage_one
                    on conflict (source_record_key_hash) do nothing
                    returning source_record_key_hash, event_occurred_at, event_id
                ),
                existing_source_record as (
                    select source_record.source_record_key_hash,
                           source_record.event_occurred_at,
                           source_record.event_id
                    from stage_one stage
                    join eventhub.event_source_record source_record
                        on source_record.source_record_key_hash = stage.source_record_key_hash
                    where not exists (
                        select 1
                        from reserved_source_record reserved
                        where reserved.source_record_key_hash = stage.source_record_key_hash
                    )
                ),
                source_record_for_stage as (
                    select *
                    from reserved_source_record
                    union all
                    select *
                    from existing_source_record
                ),
                inserted_event as (
                    insert into eventhub.event(
                        id, event_source_id, data_package_id,
                        external_source_event_id,
                        driver_id, driver_card_id, vehicle_id, vehicle_registration_id,
                        source_package_id, source_package_entity_id,
                        occurred_at, received_partner_at, received_hub_at,
                        event_domain, event_type, lifecycle,
                        odometer_m, position,
                        payload, manual_entry,
                        source_record_key_hash, event_signature_hash
                    )
                    select
                        source_record.event_id, stage.event_source_id, stage.data_package_id,
                        stage.external_source_event_id,
                        stage.driver_id, stage.driver_card_id, stage.vehicle_id, stage.vehicle_registration_id,
                        stage.source_package_id, stage.source_package_entity_id,
                        source_record.event_occurred_at, stage.received_partner_at, stage.received_hub_at,
                        stage.event_domain, stage.event_type, stage.lifecycle,
                        stage.odometer_m, case
                            when stage.longitude is null or stage.latitude is null then null
                            else ST_SetSRID(ST_MakePoint(stage.longitude, stage.latitude), 4326)::geography
                        end,
                        stage.payload, stage.manual_entry,
                        stage.source_record_key_hash, stage.event_signature_hash
                    from stage_one stage
                    join source_record_for_stage source_record
                        on source_record.source_record_key_hash = stage.source_record_key_hash
                    where not exists (
                        select 1
                        from eventhub.event existing_event
                        where existing_event.occurred_at = source_record.event_occurred_at
                          and existing_event.id = source_record.event_id
                    )
                    returning id
                )
                select count(*)
                from inserted_event
                """,
                Long.class
        );
        return insertedCount == null ? 0 : Math.toIntExact(insertedCount);
    }

    private Map<String, InsertedEventRow> resolveStagedEventRows() {
        return jdbcTemplate.query(
                """
                select distinct
                    stage.source_record_key_hash,
                    source_record.event_id,
                    source_record.event_occurred_at
                from eventhub_event_import_stage stage
                join eventhub.event_source_record source_record
                    on source_record.source_record_key_hash = stage.source_record_key_hash
                """,
                rs -> {
                    Map<String, InsertedEventRow> rows = new HashMap<>();
                    while (rs.next()) {
                        rows.put(
                                rs.getString("source_record_key_hash"),
                                new InsertedEventRow(
                                        (UUID) rs.getObject("event_id"),
                                        rs.getObject("event_occurred_at", OffsetDateTime.class)
                                )
                        );
                    }
                    return rows;
                }
        );
    }

    private void batchUpsertEventDetails(
            List<ResolvedEventImportRow> rows,
            Map<String, InsertedEventRow> eventRowsBySourceRecord
    ) {
        List<EventDetailImportRow> details = new ArrayList<>();
        for (ResolvedEventImportRow row : rows) {
            if (row.detailType() == null) {
                continue;
            }
            InsertedEventRow insertedEventRow = eventRowsBySourceRecord.get(row.sourceRecordKeyHash());
            if (insertedEventRow == null) {
                throw new IllegalStateException("Could not insert or resolve event row for source record hash " + row.sourceRecordKeyHash());
            }
            details.add(new EventDetailImportRow(
                    insertedEventRow.eventId(),
                    insertedEventRow.occurredAt(),
                    row.detailType(),
                    row.detailAttributesJson()
            ));
        }
        if (details.isEmpty()) {
            return;
        }
        jdbcTemplate.batchUpdate(
                """
                insert into eventhub.event_detail(
                    event_occurred_at, event_id, detail_type, attributes
                ) values (?, ?, ?, ?::jsonb)
                on conflict (event_occurred_at, event_id, detail_type)
                do update set
                    attributes = excluded.attributes
                """,
                new BatchPreparedStatementSetter() {
                    @Override
                    public void setValues(PreparedStatement ps, int i) throws SQLException {
                        EventDetailImportRow detail = details.get(i);
                        ps.setObject(1, detail.occurredAt());
                        ps.setObject(2, detail.eventId());
                        ps.setString(3, detail.detailType());
                        ps.setString(4, detail.attributesJson());
                    }

                    @Override
                    public int getBatchSize() {
                        return details.size();
                    }
                }
        );
    }

    private ResolvedEntityRefs resolveEntityRefs(
            String tenantKey,
            int eventSourceId,
            EventHubEventDto event,
            Map<String, UUID> entityIdCache,
            Map<String, DriverIdentityRepository.ResolvedDriverReference> driverRefCache,
            Map<String, List<VehicleRefCacheEntry>> vehicleRefCache
    ) {
        DriverIdentityRepository.ResolvedDriverReference driverRef = resolveDriverReference(tenantKey, eventSourceId, event, driverRefCache);
        ResolvedVehicleReference vehicleRef = resolveVehicleReference(tenantKey, eventSourceId, event, vehicleRefCache);
        UUID sourcePackageEntityId = resolveSourcePackageEntityId(tenantKey, eventSourceId, event, entityIdCache);
        return new ResolvedEntityRefs(
                driverRef.driverId(),
                driverRef.driverCardId(),
                vehicleRef.vehicleId(),
                vehicleRef.vehicleRegistrationId(),
                sourcePackageEntityId
        );
    }

    private DriverIdentityRepository.ResolvedDriverReference resolveDriverReference(
            String tenantKey,
            int eventSourceId,
            EventHubEventDto event,
            Map<String, DriverIdentityRepository.ResolvedDriverReference> driverRefCache
    ) {
        DriverRefDto driverRef = event.driverRef();
        if (driverRef == null || !driverRef.hasAnyReference()) {
            return DriverIdentityRepository.ResolvedDriverReference.empty();
        }
        String cacheKey = "DRIVER|" + driverRef.stableKey();
        DriverIdentityRepository.ResolvedDriverReference cached = driverRefCache.get(cacheKey);
        if (cached != null) {
            return cached;
        }
        DriverIdentityRepository.ResolvedDriverReference resolved =
                driverIdentityRepository.resolveOrCreateDriverReference(tenantKey, eventSourceId, driverRef);
        driverRefCache.put(cacheKey, resolved);
        return resolved;
    }

    private ResolvedVehicleReference resolveVehicleReference(
            String tenantKey,
            int eventSourceId,
            EventHubEventDto event,
            Map<String, List<VehicleRefCacheEntry>> vehicleRefCache
    ) {
        String cacheKey = vehicleRefCacheKey(event.vehicleRef());
        if (cacheKey != null) {
            ResolvedVehicleReference cached = findCachedVehicleReference(
                    vehicleRefCache.get(cacheKey),
                    event.occurredAt()
            );
            if (cached != null) {
                return cached;
            }
        }

        ResolvedVehicleReferenceResolution resolved = vehicleIdentityRepository.resolveOrCreateVehicleReference(
                tenantKey,
                eventSourceId,
                event.vehicleRef(),
                event.occurredAt()
        );
        if (cacheKey != null) {
            vehicleRefCache
                    .computeIfAbsent(cacheKey, ignored -> new ArrayList<>())
                    .add(buildVehicleRefCacheEntry(event.vehicleRef(), event.occurredAt(), resolved));
        }
        return resolved.reference();
    }

    private UUID resolveSourcePackageEntityId(
            String tenantKey,
            int eventSourceId,
            EventHubEventDto event,
            Map<String, UUID> entityIdCache
    ) {
        SourcePackageRefDto sourcePackageRef = event.sourcePackageRef();
        if (sourcePackageRef == null || !sourcePackageRef.hasAnyReference()) {
            return null;
        }

        String packageKind = normalizeNullable(sourcePackageRef.packageKind());
        String sourcePackageId = normalizeNullable(sourcePackageRef.sourcePackageId());

        String sourceEntityId = normalizeNullable(sourcePackageRef.sourceEntityId());
        if (sourceEntityId == null && sourcePackageId != null) {
            sourceEntityId = "SOURCE_PACKAGE:" + (packageKind == null ? "UNKNOWN" : packageKind) + ":" + sourcePackageId;
        }
        if (sourceEntityId == null) {
            return null;
        }

        Map<String, Object> payload = new LinkedHashMap<>();
        put(payload, "package_kind", sourcePackageRef.packageKind());
        put(payload, "source_package_id", sourcePackageRef.sourcePackageId());
        put(payload, "source_entity_id", sourcePackageRef.sourceEntityId());
        put(payload, "package_period_from", sourcePackageRef.packagePeriodFrom());
        put(payload, "package_period_to", sourcePackageRef.packagePeriodTo());
        put(payload, "imported_into_source_at", sourcePackageRef.importedIntoSourceAt());

        String displayName = packageKind == null ? sourceEntityId : packageKind + ":" + (sourcePackageId == null ? sourceEntityId : sourcePackageId);
        return resolveEntityId(
                tenantKey,
                eventSourceId,
                "SOURCE_PACKAGE",
                sourceEntityId,
                sourcePackageId,
                displayName,
                null,
                payload,
                entityIdCache
        );
    }

    private UUID resolveEntityId(
            String tenantKey,
            int eventSourceId,
            String entityType,
            String sourceEntityId,
            String sourceExternalKey,
            String displayName,
            Boolean active,
            Map<String, Object> payload,
            Map<String, UUID> entityIdCache
    ) {
        String normalizedSourceEntityId = normalizeNullable(sourceEntityId);
        if (normalizedSourceEntityId == null) {
            return null;
        }

        String cacheKey = entityType + "|" + normalizedSourceEntityId;
        UUID cached = entityIdCache.get(cacheKey);
        if (cached != null) {
            return cached;
        }

        UUID resolved = sourceMasterDataRepository.resolveOrCreateEntityId(
                tenantKey,
                eventSourceId,
                entityType,
                normalizedSourceEntityId,
                normalizeNullable(sourceExternalKey),
                normalizeNullable(displayName),
                active,
                payload
        );
        entityIdCache.put(cacheKey, resolved);
        return resolved;
    }

    private String normalizeNullable(String value) {
        if (value == null) {
            return null;
        }
        String trimmed = value.trim();
        return trimmed.isEmpty() ? null : trimmed;
    }

    private void put(Map<String, Object> target, String key, Object value) {
        if (value != null) {
            target.put(key, value);
        }
    }

    private String vehicleRefCacheKey(VehicleRefDto vehicleRef) {
        if (vehicleRef == null || !vehicleRef.hasAnyReference()) {
            return null;
        }

        String sourceVehicleEntityId = normalizeNullable(vehicleRef.sourceVehicleEntityId());
        String vin = normalizeNullable(vehicleRef.vin());
        String sourceRegistrationEntityId = normalizeNullable(vehicleRef.sourceRegistrationEntityId());
        VehicleRegistrationRefDto registration = vehicleRef.vehicleRegistration();
        String registrationNationKey = registration == null
                ? null
                : registration.nationNumericCode() == null
                ? normalizeNullable(registration.nation())
                : registration.nationNumericCode().toString();
        String registrationNumber = registration == null ? null : normalizeNullable(registration.number());

        return String.join("|",
                sourceVehicleEntityId == null ? "" : sourceVehicleEntityId,
                vin == null ? "" : vin,
                sourceRegistrationEntityId == null ? "" : sourceRegistrationEntityId,
                registrationNationKey == null ? "" : registrationNationKey,
                registrationNumber == null ? "" : registrationNumber
        );
    }

    private ResolvedVehicleReference findCachedVehicleReference(
            List<VehicleRefCacheEntry> cacheEntries,
            OffsetDateTime occurredAt
    ) {
        if (cacheEntries == null || cacheEntries.isEmpty()) {
            return null;
        }
        for (VehicleRefCacheEntry cacheEntry : cacheEntries) {
            if (cacheEntry.matches(occurredAt)) {
                return cacheEntry.reference();
            }
        }
        return null;
    }

    private VehicleRefCacheEntry buildVehicleRefCacheEntry(
            VehicleRefDto vehicleRef,
            OffsetDateTime occurredAt,
            ResolvedVehicleReferenceResolution resolved
    ) {
        boolean hasDirectVehicleIdentity = hasDirectVehicleIdentity(vehicleRef);
        if (hasDirectVehicleIdentity) {
            return new VehicleRefCacheEntry(resolved.reference(), null, null, null);
        }
        if (resolved.resolvedFromAssignment()) {
            return new VehicleRefCacheEntry(
                    resolved.reference(),
                    resolved.assignmentValidFrom(),
                    resolved.assignmentValidTo(),
                    null
            );
        }
        return new VehicleRefCacheEntry(resolved.reference(), null, null, occurredAt);
    }

    private boolean hasDirectVehicleIdentity(VehicleRefDto vehicleRef) {
        if (vehicleRef == null) {
            return false;
        }
        return normalizeNullable(vehicleRef.sourceVehicleEntityId()) != null
                || normalizeNullable(vehicleRef.vin()) != null;
    }

    private String toJson(JsonNode value) {
        try {
            return objectMapper.writeValueAsString(value == null ? objectMapper.createObjectNode() : value);
        } catch (JsonProcessingException e) {
            throw new IllegalArgumentException("Cannot serialize JSONB value", e);
        }
    }

    private record ResolvedEntityRefs(
            UUID driverId,
            UUID driverCardId,
            UUID vehicleId,
            UUID vehicleRegistrationId,
            UUID sourcePackageEntityId
    ) {
    }

    private record InsertedEventRow(
            UUID eventId,
            OffsetDateTime occurredAt
    ) {
    }

    private record ResolvedEventImportRow(
            String sourceRecordKeyHash,
            UUID requestedEventId,
            UUID packageId,
            int eventSourceId,
            String externalSourceEventId,
            UUID driverId,
            UUID driverCardId,
            UUID vehicleId,
            UUID vehicleRegistrationId,
            String sourcePackageId,
            UUID sourcePackageEntityId,
            OffsetDateTime occurredAt,
            OffsetDateTime receivedPartnerAt,
            OffsetDateTime receivedHubAt,
            String eventDomain,
            String eventType,
            String lifecycle,
            Long odometerM,
            Double longitude,
            Double latitude,
            String payloadJson,
            boolean manualEntry,
            String eventSignatureHash,
            String detailType,
            String detailAttributesJson
    ) {
    }

    private record EventDetailImportRow(
            UUID eventId,
            OffsetDateTime occurredAt,
            String detailType,
            String attributesJson
    ) {
    }

    private record VehicleRefCacheEntry(
            ResolvedVehicleReference reference,
            OffsetDateTime validFrom,
            OffsetDateTime validTo,
            OffsetDateTime exactOccurredAt
    ) {
        private boolean matches(OffsetDateTime occurredAt) {
            if (exactOccurredAt != null) {
                return exactOccurredAt.equals(occurredAt);
            }
            if (validFrom == null && validTo == null) {
                return true;
            }
            boolean fromMatches = validFrom == null || occurredAt == null || !occurredAt.isBefore(validFrom);
            boolean toMatches = validTo == null || occurredAt == null || occurredAt.isBefore(validTo);
            return fromMatches && toMatches;
        }
    }
}
