package kr.or.bok.rtgs.sequencer

import com.fasterxml.jackson.databind.ObjectMapper
import kr.or.bok.rtgs.common.InboundRequest
import kr.or.bok.rtgs.common.JournalEntry
import kr.or.bok.rtgs.common.Topics
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.kafka.annotation.KafkaListener
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service

/**
 * 전역 순번기(Sequencer). 입구(rtgs.inbound)에서 접수 요청을 받아 전역순번을 부여하고,
 * 결정론적 값(수신시각)을 확정해 저널(rtgs.journal)로 발행한다.
 *
 * **B1 삼중화 — durable-before-publish (무손실):**
 * 1. 순번 = Postgres SEQUENCE `global_seq_seq`.nextval (원자적·durable). 인메모리 카운터 폐기.
 * 2. JournalEntry JSON을 `journal_outbox`에 커밋(published=false) → 그 후 rtgs.journal 발행 → published=true.
 * 3. 발행 전 크래시/리더승계로 죽어도, 재발행 스케줄러가 미발행분을 재송신(at-least-once).
 *    소비측(Dior/Hermes)은 seq 기준 멱등이라 중복은 무해.
 *
 * **리더선출**: 순번기 3인스턴스를 같은 컨슈머그룹 `sequencer`로 rtgs.inbound(단일 파티션) 구독 →
 * Kafka가 파티션을 1개 인스턴스에만 배정 = 자동 단일 리더. 리더 死 시 리밸런스로 자동 승계.
 * 승계 리더는 재발행 스케줄러로 outbox 미발행분을 이어서 내보낸다.
 *
 * **펜싱**: nextval이 원자적이라 순간적 이중리더가 있어도 서로 다른 seq만 발급 → 저널 오염 없음.
 * (nextval 소비 후 outbox insert 실패 시 그 seq는 phantom 결번이 되며, 소비측 B3 타임아웃이 정리.)
 */
@Service
class SequencerService(
    private val kafka: KafkaTemplate<String, String>,
    private val mapper: ObjectMapper,
    private val jdbc: JdbcTemplate,
    @Value("\${rtgs.outbox-republish-grace-ms:3000}") private val republishGraceMs: Long,
) {
    private val log = LoggerFactory.getLogger(javaClass)

    @KafkaListener(topics = [Topics.INBOUND], groupId = "sequencer", concurrency = "1")
    fun onInbound(payload: String) {
        val req = mapper.readValue(payload, InboundRequest::class.java)
        // 1) durable 순번 발번(SEQUENCE)
        val globalSeq = jdbc.queryForObject("SELECT nextval('global_seq_seq')", Long::class.java)!!
        val entry = JournalEntry(
            globalSeq = globalSeq,
            seqEpochMillis = System.currentTimeMillis(),
            originCenter = req.originCenter,
            core = req.core,
        )
        val json = mapper.writeValueAsString(entry)
        // 2) 발행 전 outbox 커밋(durable-before-publish)
        jdbc.update(
            "INSERT INTO journal_outbox(global_seq, bmi, payload, published, created_at) VALUES (?,?,?,FALSE,?)",
            globalSeq, entry.core.bmi, json, entry.seqEpochMillis,
        )
        // 3) 발행 후 published=true
        publish(globalSeq, entry.core.bmi, json)
        log.info("SEQ #{} bmi={} {}->{} amt={} origin={}",
            globalSeq, req.core.bmi, req.core.senderCode, req.core.receiverCode, req.core.amount, req.originCenter)
    }

    /**
     * 미발행 아웃박스 재발행(무손실·리더승계). 인라인 발행과의 경합을 피하려고 grace 이후 항목만.
     * 단일 파티션 순서 유지를 위해 global_seq 오름차순으로 재발행.
     */
    @Scheduled(fixedDelayString = "\${rtgs.outbox-republish-ms:2000}")
    fun republishUnpublished() {
        val cutoff = System.currentTimeMillis() - republishGraceMs
        val rows = jdbc.queryForList(
            "SELECT global_seq, bmi, payload FROM journal_outbox WHERE NOT published AND created_at < ? ORDER BY global_seq",
            cutoff,
        )
        if (rows.isEmpty()) return
        for (row in rows) {
            val seq = (row["global_seq"] as Number).toLong()
            val bmi = (row["bmi"] as String?) ?: ""
            val json = row["payload"] as String
            publish(seq, bmi, json)
            log.warn("REPUBLISH seq=#{} bmi={} (미발행분 재송신)", seq, bmi)
        }
    }

    /** rtgs.journal 발행 후 outbox를 published=true로 마킹. */
    private fun publish(globalSeq: Long, bmi: String, json: String) {
        kafka.send(Topics.JOURNAL, bmi, json)
        jdbc.update("UPDATE journal_outbox SET published = TRUE WHERE global_seq = ?", globalSeq)
    }
}
