package hr.com.port.ips.eracun.service;

import com.google.gson.JsonObject;
import hr.com.port.connectionPool.__Pool;
import hr.com.port.eracun.codelists.NacinPlacanjaCode;
import hr.com.port.eracun.codelists.UnitOfMeasureCode;
import hr.com.port.functions.Functions;
import hr.com.port.ips.eracun.boot.EracunSyncConfig;
import hr.com.port.ips.eracun.dao.DokumentiVrsteDao;
import hr.com.port.ips.eracun.dao.EracunDokumentDao;
import hr.com.port.ips.eracun.dao.EracunDokumentLogDao;
import hr.com.port.ips.eracun.dao.EracunSyncStateDao;
import hr.com.port.ips.eracun.helper.DocumentNrResolver;
import hr.com.port.ips.eracun.helper.DokumentFileHelper;
import hr.com.port.ips.eracun.provider.mer.MerClient;
import hr.com.port.ips.eracun.provider.mer.enums.MerDocumentType;
import hr.com.port.ips.eracun.provider.mer.enums.MerFiscalizationStatus;
import hr.com.port.ips.eracun.provider.mer.enums.MerMarkPaidStatus;
import hr.com.port.ips.eracun.provider.mer.enums.MerProcessStatus;
import hr.com.port.ips.eracun.provider.mer.enums.MerRejectStatus;
import hr.com.port.ips.eracun.provider.mer.dto.document.ReceiveRequest;
import hr.com.port.ips.eracun.provider.mer.dto.document.FiscalizationStatusResponse;
import hr.com.port.ips.eracun.provider.mer.dto.document.InboxDocumentHeader;
import hr.com.port.ips.eracun.provider.mer.dto.ereporting.MarkPaidResponse;
import hr.com.port.ips.eracun.provider.mer.dto.document.OutboxDocumentHeader;
import hr.com.port.ips.eracun.provider.mer.dto.document.QueryDocumentProcessStatusInboxResponse;
import hr.com.port.ips.eracun.provider.mer.dto.document.QueryDocumentProcessStatusOutboxResponse;
import hr.com.port.ips.eracun.provider.mer.dto.document.QueryInboxResponse;
import hr.com.port.ips.eracun.provider.mer.dto.document.QueryOutboxResponse;
import hr.com.port.ips.eracun.provider.mer.dto.document.ReceiveResponse;
import hr.com.port.ips.eracun.provider.mer.dto.ereporting.RejectResponse;
import hr.com.port.ips.eracun.modeli.EracunDokument;
import hr.com.port.ips.eracun.modeli.EracunDokumentLog;
import hr.com.port.ips.eracun.modeli.EracunStatus;
import hr.com.port.ips.eracun.modeli.EracunSyncState;
import hr.com.port.ips.eracun.modeli.PosrednikType;
import hr.com.port.ips.eracun.resilience.mer.MerAvailabilityGate;
import hr.com.port.ips.eracun.resilience.mer.MerSoftFailContext;
import hr.com.port.ips.eracun.resilience.mer.MerTriageHelper;
import hr.com.port.ips.modeli.EracunUlazniModel;
import hr.com.port.modeli.PodaciFirmaModel;

import java.io.IOException;
import java.io.StringReader;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.sql.Connection;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.sql.Types;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBElement;
import javax.xml.bind.JAXBException;
import javax.xml.bind.Unmarshaller;
import javax.xml.datatype.XMLGregorianCalendar;
import javax.xml.transform.stream.StreamSource;

import oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_2.AddressType;
import oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_2.InvoiceLineType;
import oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_2.PartyType;
import oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_2.PaymentMeansType;
import oasis.names.specification.ubl.schema.xsd.commonbasiccomponents_2.CurrencyCodeType;
import oasis.names.specification.ubl.schema.xsd.commonbasiccomponents_2.IDType;
import oasis.names.specification.ubl.schema.xsd.commonbasiccomponents_2.NameType;
import oasis.names.specification.ubl.schema.xsd.commonbasiccomponents_2.NoteType;
import oasis.names.specification.ubl.schema.xsd.invoice_2.InvoiceType;
import oasis.names.specification.ubl.schema.xsd.unqualifieddatatypes_2.DateType;
import org.apache.log4j.Logger;
import un.unece.uncefact.data.specification.corecomponenttypeschemamodule._2.CodeType;
import un.unece.uncefact.data.specification.corecomponenttypeschemamodule._2.IdentifierType;
import un.unece.uncefact.data.specification.corecomponenttypeschemamodule._2.TextType;

public class EracunSyncService {
    static Logger logger = Logger.getLogger(EracunSyncService.class);
    private final EracunDokumentDao dokumentDao;
    private final EracunDokumentLogDao logDao;
    private final EracunSyncConfig cfg;
    private final EracunSyncStateDao syncStateDao;
    private final MerClient merClient;
    private static final long SAFETY_MARGIN_SEC = 30;
    private static final long PROCESS_DEFAULT_LOOKBACK_SEC = 200 * 3600; // prvi run: 200h unatrag

    public EracunSyncService(EracunDokumentDao dokumentDao,
                             EracunDokumentLogDao logDao,
                             EracunSyncConfig cfg,
                             EracunSyncStateDao syncStateDao) {

        this.dokumentDao = dokumentDao;
        this.logDao = logDao;
        this.cfg = cfg;
        this.syncStateDao = syncStateDao;
        this.merClient = new MerClient(
                cfg.baseUrl(),
                Long.parseLong(cfg.username()),
                cfg.password(),
                cfg.companyId(),
                cfg.companyBu(),
                cfg.softwareId()
        ); // MerClient izgrađen iz properties-a
    }

    public void processQueryInboxResponse(QueryInboxResponse response, Connection conn) throws SQLException {
        if (response == null || response.documents == null || response.documents.isEmpty()) {
            logger.info("Nema ulaznih dokumenata za obradu.");
            return;
        }

        String checkSql = "SELECT 1 FROM eracuni_ulazni WHERE electronic_id = ?";
        String insertSql = "INSERT INTO eracuni_ulazni (" +
                "electronic_id, godina, invoice_id, document_type_code, document_type_name, valuta, " +
                "issue_date, dobavljac_oib, dobavljac_naziv, dobavljac_endpoint" +
                ") VALUES (?,?,?,?,?,?,?,?,?,?)";

        try (PreparedStatement checkStmt = conn.prepareStatement(checkSql);
             PreparedStatement insertStmt = conn.prepareStatement(insertSql)) {

            for (InboxDocumentHeader hdr : response.documents) {
                logger.debug("📨 Obrada inbox headera: EID=" + hdr.getElectronicId() +
                        ", DocumentType=" + hdr.getDocumentTypeName() +
                        ", IssueDate=" + hdr.getIssueDate());

                // Provjeri postoji li već
                checkStmt.setLong(1, hdr.getElectronicId());
                try (ResultSet rs = checkStmt.executeQuery()) {
                    if (rs.next()) {
                        logger.info("⏩ Ulazni eRačun već postoji, preskačem. EID=" + hdr.getElectronicId());
                        continue;
                    }
                }

                // Godina iz datuma izdavanja
                int godina = 0;
                if (hdr.getIssueDate() != null && !hdr.getIssueDate().isEmpty()) {
                    try {
                        Timestamp t = parseTimestamp(hdr.getIssueDate());
                        if (t != null) {
                            godina = t.toLocalDateTime().getYear();
                        } else {
                            godina = LocalDate.parse(hdr.getIssueDate().substring(0, 10)).getYear();
                        }
                    } catch (Exception e) {
                        logger.warn("⚠️ Ne mogu parsirati IssueDate za ElectronicId=" + hdr.getElectronicId() + ":" + hdr.getIssueDate());
                    }
                }

                // mapiranje tipa dokumenta (sigurno)
				String docTypeCode = null;
				String docTypeName = hdr.getDocumentTypeName();
				try {
					MerDocumentType mdt = MerDocumentType.fromId(hdr.getDocumentTypeId());
					if (mdt != null) {
						docTypeCode = mdt.getUblCode();
						if (docTypeName == null || docTypeName.trim().isEmpty()) {
							docTypeName = mdt.getNaziv();
						}
					}
				} catch (Throwable ignore) { /* best-effort */ }

                // Insert u eracuni_ulazni
                insertStmt.setLong(1, hdr.getElectronicId());
                insertStmt.setInt(2, godina);
                insertStmt.setString(3, hdr.getDocumentNr());
                insertStmt.setString(4, docTypeCode);
                insertStmt.setString(5, docTypeName);
                insertStmt.setString(6, null); // valuta — dopuni iz XML-a u persistIncomingFromXml
                insertStmt.setDate(7, hdr.getIssueDate() != null && hdr.getIssueDate().length() >= 10
                        ? Date.valueOf(hdr.getIssueDate().substring(0, 10)) : null);
                insertStmt.setString(8, hdr.getSenderBusinessNumber());
                insertStmt.setString(9, hdr.getSenderBusinessName());
                insertStmt.setString(10, hdr.getSenderBusinessUnit());
                insertStmt.executeUpdate();
                logger.info("✅ Dodan ulazni eRačun u eracuni_ulazni. EID=" + hdr.getElectronicId());

                // ➕ Osiguraj zapis u eracun_dokument (ulazni)
                boolean postoji = (dokumentDao.findByElectronicId(hdr.getElectronicId(), false, conn) != null);
                if (!postoji) {
                    EracunDokument novi = new EracunDokument();
                    novi.setIzlazni(false);
                    novi.setVrstaDokumenta(29);
                    novi.setVrstaDokumentaNaziv("Ulazni eračun");
                    novi.setGodina(godina);
                    novi.setElectronicId(hdr.getElectronicId());
                    novi.setPosrednik(cfg.posrednikId());
                    novi.setPosrednikNaziv(PosrednikType.fromId(cfg.posrednikId()).getCode());

                    // ↓↓↓ maksimum iz headera (sigurno)
                    novi.setDocumentNr(hdr.getDocumentNr());
                    novi.setDocumentTypeId(hdr.getDocumentTypeId());
                    novi.setDocumentTypeName(docTypeName);

                    novi.setPartnerBusinessNumber(hdr.getSenderBusinessNumber());
                    novi.setPartnerBusinessUnit(hdr.getSenderBusinessUnit());
                    novi.setPartnerBusinessName(hdr.getSenderBusinessName());

                    novi.setTransportniStatusId(hdr.getStatusId());
                    novi.setTransportniStatusNaziv(hdr.getStatusName());

                    // lokalni početni
                    novi.setLokalniStatusId(EracunStatus.ZAPRIMLJEN.getId());
                    novi.setLokalniStatusNaziv(EracunStatus.ZAPRIMLJEN.getNaziv());

                    // header datumi
                    novi.setSent(parseTimestamp(hdr.getSent()));
                    novi.setDelivered(parseTimestamp(hdr.getDelivered()));
                    novi.setDatumZadnjegStatusa(new java.sql.Timestamp(System.currentTimeMillis()));

                    // Tip dokumenta (MER ID); UBL tip (380) će stići iz XML-a
                    novi.setTipDokumenta(String.valueOf(hdr.getDocumentTypeId()));
                    novi.setTipDokumentaNaziv(hdr.getDocumentTypeName() != null ? hdr.getDocumentTypeName() : "Račun");

                    dokumentDao.insertNoviDokument(conn, novi);
                    logger.info("Upisan stub u eracun_dokument (ulazni) za EID=" + hdr.getElectronicId());

                    // odmah povuci i procesni status za ovaj novi ulazni dokument
                    backfillInboxProcessStatusFor(hdr.getElectronicId(), conn);
                }

                try {
                    // izračun issueDateTime (ISO s/bez offseta)
                    LocalDateTime issuedAt = null;
                    Timestamp ts = parseTimestamp(hdr.getIssueDate());
                    if (ts != null) {
                        issuedAt = ts.toLocalDateTime();
                    } else if (hdr.getIssueDate() != null && hdr.getIssueDate().length() >= 10) {
                        issuedAt = LocalDate.parse(hdr.getIssueDate().substring(0, 10)).atStartOfDay();
                    }

                    // Preuzmi XML i spremi – smjer UL (ulazni)
                    String vrsta = String.valueOf(new EracunUlazniModel().getVrstaDokumenta());
                    ReceiveResult receiveResult = receiveInvoice(
                            this.merClient,
                            hdr.getElectronicId(),
                            PosrednikType.fromId(cfg.posrednikId()).getCode(), // koristiš već u klasi
                            "ulazni",
                            hdr.getSenderBusinessNumber(),   // porezni broj za ulazni
                            "000",                           // tip code za ulazni
                            "Ulazni eRačun",                 // naziv dokumenta (informativno)
                            vrsta,                           // vrsta dokumenta (ulazni)
                            (hdr.getIssueDate() != null && hdr.getIssueDate().length() >= 4) ? hdr.getIssueDate().substring(0, 4) : "0",
                            hdr.getDocumentNr(),             // broj e-računa iz headera
                            issuedAt,                        // može biti null, helper rješava
                            null,                            // baseDir (rezervirano)
                            conn
                    );

                    persistIncomingFromXml(
                            hdr.getElectronicId(),
                            receiveResult.xmlContent,
                            receiveResult.filePath.toString(),
                            cfg.posrednikId(),
                            PosrednikType.fromId(cfg.posrednikId()).getCode(),
                            conn
                    );

                } catch (Exception ex) {
                    logger.error(new Functions().logging(ex));
                    // Ne prekidaj cijeli sync zbog jednog neuspjelog receive-a
                }

                // 📌 Logiraj snapshot za ulazni header (nemamo ključ dokumenta pa stavljamo 0)
                EracunDokumentLog log = new EracunDokumentLog();
                log.setIzlazni(false);
                log.setGodina(godina);
                log.setPosrednik(0);
                log.setVrstaDokumenta(0);
                log.setOnu(0);
                log.setOpp(0);
                log.setBroj(0);
                log.setElectronicId(hdr.getElectronicId());

                java.sql.Timestamp now = new java.sql.Timestamp(System.currentTimeMillis());
                log.setDatumStatusa(now);
                log.setDatumPromjene(now);

                log.setPorukaGreske(null);
                log.setOpis("Sync (inbox): upisan eracuni_ulazni");
                log.setPutanjaXml(null);
                log.setIzvor(PosrednikType.fromId(cfg.posrednikId()).getCode());
                log.setAkcija("SNAPSHOT_QUERY");
                log.setOibOperatera(null);

                logDao.insert(log, conn);
            }
        }
    }

    public void processQueryOutboxResponse(QueryOutboxResponse response, Connection conn) {
        if (response == null || response.getDocumentHeaders() == null || response.getDocumentHeaders().isEmpty()) {
            logger.info("Nema outbox headera za obradu.");
            return;
        }

        for (OutboxDocumentHeader header : response.getDocumentHeaders()) {

            EracunDokument doc = dokumentDao.findByElectronicId(header.getElectronicId(), true, conn);
            if (doc != null) {
                logger.info("POSTOJEĆI DOKUMENT - processQueryOutboxResponse");
                // ✅ Postojeći dokument → updateStatus + log snapshot (ako se TRANSPORTNI status promijenio)
                Integer headerStatusId = header.getStatusId();
                if (!java.util.Objects.equals(doc.getTransportniStatusId(), headerStatusId)) {

                    // Transportni iz MER headera
                    doc.setTransportniStatusId(headerStatusId);
                    doc.setTransportniStatusNaziv(header.getStatusName());

                    // Lokalni – mapiraj iz transportnog ID-a (ako postoji mapping)
                    EracunStatus local = EracunStatus.fromId(headerStatusId);
                    if (local != null) {
                        doc.setLokalniStatusId(local.getId());
                        doc.setLokalniStatusNaziv(local.getNaziv());
                    }

                    // (Procesni zasad ne diramo dok ga ne dobijemo iz MER-a)
                    doc.setDatumZadnjegStatusa(parseTimestamp(header.getUpdated()));
                    dokumentDao.updateStatus(doc);

                    // Log promjene statusa
                    EracunDokumentLog log = new EracunDokumentLog();
                    log.setIzlazni(true);
                    log.setGodina(doc.getGodina());
                    log.setPosrednik(doc.getPosrednik());
                    log.setVrstaDokumenta(doc.getVrstaDokumenta());
                    log.setOnu(doc.getOnu());
                    log.setOpp(doc.getOpp());
                    log.setBroj(doc.getBroj());
                    log.setElectronicId(doc.getElectronicId());

                    log.setTransportniStatusId(headerStatusId);
                    log.setTransportniStatusNaziv(header.getStatusName());
                    if (local != null) {
                        log.setLokalniStatusId(local.getId());
                        log.setLokalniStatusNaziv(local.getNaziv());
                    } else {
                        log.setLokalniStatusId(doc.getLokalniStatusId());
                        log.setLokalniStatusNaziv(doc.getLokalniStatusNaziv());
                    }

                    log.setDatumStatusa(parseTimestamp(header.getUpdated()));
                    log.setDatumPromjene(new java.sql.Timestamp(System.currentTimeMillis()));
                    log.setPorukaGreske(null);
                    log.setOpis("Promjena statusa (outbox)");
                    log.setIzvor(PosrednikType.fromId(cfg.posrednikId()).getCode());
                    log.setAkcija("QUERY");

                    logDao.insert(log, conn);
                }

                // 📌 Uvijek upiši snapshot nakon sync-a (neovisno o promjeni)
                EracunDokumentLog snap = new EracunDokumentLog();
                snap.setIzlazni(true);
                snap.setGodina(doc.getGodina());
                snap.setPosrednik(doc.getPosrednik());
                snap.setVrstaDokumenta(doc.getVrstaDokumenta());
                snap.setOnu(doc.getOnu());
                snap.setOpp(doc.getOpp());
                snap.setBroj(doc.getBroj());
                snap.setElectronicId(doc.getElectronicId());

                snap.setTransportniStatusId(doc.getTransportniStatusId());
                snap.setTransportniStatusNaziv(doc.getTransportniStatusNaziv());
                snap.setProcesniStatusId(doc.getProcesniStatusId());
                snap.setProcesniStatusNaziv(doc.getProcesniStatusNaziv());
                snap.setLokalniStatusId(doc.getLokalniStatusId());
                snap.setLokalniStatusNaziv(doc.getLokalniStatusNaziv());

                java.sql.Timestamp now = new java.sql.Timestamp(System.currentTimeMillis());
                snap.setDatumStatusa(now);
                snap.setDatumPromjene(now);
                snap.setPorukaGreske(doc.getPorukaGreske());
                snap.setOpis("Sync snapshot statusa (outbox)");
                snap.setIzvor(PosrednikType.fromId(cfg.posrednikId()).getCode());
                snap.setAkcija("SNAPSHOT_QUERY");

                logDao.insert(snap, conn);

            } else {  // SIROČE
                // 🆕 Nema dokumenta → insert novog + početni log + snapshot
                logger.info("SIROČE - processQueryOutboxResponse");
                EracunDokument novi = fromHeader(header, conn, true);

                try {
                    dokumentDao.insertNoviDokument(conn, novi);

                    // povuci procesni status za ovaj novi outbox dokument
                    backfillOutboxProcessStatusFor(header.getElectronicId(), conn);
                    System.out.println("backfill outbox process status za id:" + header.getElectronicId());

                    // Početni log
                    EracunDokumentLog log = new EracunDokumentLog();
                    log.setIzlazni(true);
                    log.setGodina(novi.getGodina());
                    log.setPosrednik(novi.getPosrednik());
                    log.setVrstaDokumenta(novi.getVrstaDokumenta());
                    log.setOnu(novi.getOnu());
                    log.setOpp(novi.getOpp());
                    log.setBroj(novi.getBroj());
                    log.setElectronicId(novi.getElectronicId());

                    log.setTransportniStatusId(novi.getTransportniStatusId());
                    log.setTransportniStatusNaziv(novi.getTransportniStatusNaziv());
                    log.setProcesniStatusId(novi.getProcesniStatusId());
                    log.setProcesniStatusNaziv(novi.getProcesniStatusNaziv());
                    log.setLokalniStatusId(novi.getLokalniStatusId());
                    log.setLokalniStatusNaziv(novi.getLokalniStatusNaziv());

                    java.sql.Timestamp now = new java.sql.Timestamp(System.currentTimeMillis());
                    log.setDatumStatusa(now);
                    log.setDatumPromjene(now);
                    log.setPorukaGreske(null);
                    log.setOpis("Kreiran zapis iz queryOutbox");
                    log.setIzvor(PosrednikType.fromId(cfg.posrednikId()).getCode());
                    log.setAkcija("QUERY");

                    logDao.insert(log, conn);

                } catch (SQLException e) {
                    logger.error(new Functions().logging(e));
                }
            }
        }
    }

    private static final class ReceiveResult {
        final String xmlContent;
        final java.nio.file.Path filePath;

        ReceiveResult(String xmlContent, java.nio.file.Path filePath) {
            this.xmlContent = xmlContent;
            this.filePath = filePath;
        }
    }

    public ReceiveResult receiveInvoice(
            MerClient merClient,
            long electronicId,
            String posrednikCode,
            String smjer,                  // "ulazni" ili "izlazni"
            String porezniBrojIliOib,      // ulazni: porezni broj; izlazni: OIB (bez "HR")
            String tipCode,                // ulazni: "000"; izlazni: iz modela
            String nazivDokumenta,         // ulazni: "Ulazni eRačun"; izlazni: iz modela (npr. "Račun")
            String vrstaDokumenta,         // ulazni: "00"; izlazni: iz modela
            String godina,                 // može biti null – prepisujemo iz XML-a
            String brojEracuna,
            LocalDateTime issueDateTime,   // može biti null – prepisujemo iz XML-a
            Path baseDir,                  // rezervirano (DokumentFileHelper računa putanju)
            Connection conn
    ) throws IOException, SQLException {

        logger.info("RECEIVE INVOICE Preuzimam eRačun, ElectronicId=" + electronicId);

        // 1) MER receive
        ReceiveRequest req = new ReceiveRequest(
                merClient.getUsername(),
                merClient.getPassword(),
                merClient.getCompanyId(),
                merClient.getCompanyBu(),
                merClient.getSoftwareId(),
                electronicId
        );
        ReceiveResponse resp = merClient.receive(req);
        if (resp == null || resp.xmlContent == null || resp.xmlContent.trim().isEmpty()) {
            throw new IOException("Prazan XML vraćen za ElectronicId=" + electronicId);
        }

        // 2) JAXB unmarshal minimalno što nam treba (IssueDate)
        LocalDate issueDateFromXml = null;
        try {
            JAXBContext jaxb = JAXBContext.newInstance(InvoiceType.class);
            Unmarshaller um = jaxb.createUnmarshaller();
            InvoiceType inv = um.unmarshal(new StreamSource(new StringReader(resp.xmlContent)), InvoiceType.class).getValue();

            if (inv != null && inv.getIssueDate() != null) {
                XMLGregorianCalendar x = inv.getIssueDate().getValue();
                if (x != null) {
                    issueDateFromXml = LocalDate.of(x.getYear(), x.getMonth(), x.getDay());
                }
            }
        } catch (Exception ex) {
            // Nije kritično za receive; fallback je na ulazne argumente
            logger.error(new Functions().logging(ex));
        }

        // 2a) "Vrijeme izdavanja: HH:mm:ss" iz <cbc:Note> – regex nad stringom (brzo i bez NoteType ovisnosti)
        LocalTime timeFromXml = null;
        try {
            Matcher m = Pattern.compile("Vrijeme izdavanja:\\s*(\\d{2}:\\d{2}:\\d{2})").matcher(resp.xmlContent);
            if (m.find()) timeFromXml = LocalTime.parse(m.group(1));
        } catch (Exception ignore) { }

        // 2b) Efektivne vrijednosti (ako nema u XML-u, koristi argumente)
        LocalDateTime effectiveIssueDateTime = issueDateTime;
        String effectiveGodina = godina;
        if (issueDateFromXml != null) {
            if (timeFromXml == null) timeFromXml = LocalTime.MIDNIGHT;
            effectiveIssueDateTime = LocalDateTime.of(issueDateFromXml, timeFromXml);
            effectiveGodina = String.valueOf(issueDateFromXml.getYear());
        }
        int vrsta = 0;
        try { vrsta = Integer.parseInt(vrstaDokumenta); } catch (Exception ignore) {}
        int god = 0;
        try { if (effectiveGodina != null && effectiveGodina.matches("\\d{4}")) god = Integer.parseInt(effectiveGodina); } catch (Exception ignore) {}

        // 3) Putanja s ispravnom godinom i IssueDateTime
        Path filePath = DokumentFileHelper.getFilePath(
                smjer,
                porezniBrojIliOib,
                tipCode,
                nazivDokumenta,
                vrsta,
                god,
                brojEracuna,
                effectiveIssueDateTime,
                electronicId
        );

        // 4) Zapiši XML
        Files.createDirectories(filePath.getParent());
        Files.write(filePath, resp.xmlContent.getBytes(StandardCharsets.UTF_8));
        logger.info("XML spremljen: " + filePath.toString());

        // 5) Log RECEIVE
        EracunDokumentLog log = new EracunDokumentLog();
        log.setIzlazni("izlazni".equalsIgnoreCase(smjer));
        log.setGodina(god);
        log.setPosrednik(0);
        log.setVrstaDokumenta(0);
        log.setOnu(0);
        log.setOpp(0);
        log.setBroj(0);
        log.setElectronicId(electronicId);

        Timestamp now = new Timestamp(System.currentTimeMillis());
        log.setDatumStatusa(now);
        log.setDatumPromjene(now);
        log.setPorukaGreske(null);
        log.setOpis("Preuzet XML od posrednika");
        log.setPutanjaXml(filePath.toString());
        log.setIzvor(PosrednikType.fromId(cfg.posrednikId()).getCode());
        log.setAkcija("RECEIVE");
		
        logDao.upsert(log, conn);
        logger.info("Upisan eracun_dokument_log (RECEIVE) za ElectronicId=" + electronicId);

        // 6) Update centralnog zapisa (bez fall-back inserta za IN)
        final boolean isIzlazni = "izlazni".equalsIgnoreCase(smjer);
        EracunDokument centar = dokumentDao.findByElectronicId(electronicId, isIzlazni, conn);

        if (!isIzlazni && centar == null) {
            logger.warn("IN receiveInvoice: stub ne postoji (eid=" + electronicId + ") – preskačem update putanje.");
            return new ReceiveResult(resp.xmlContent, filePath);
        }
        if (isIzlazni && centar == null) {
            // (po potrebi) fallback samo za OUT
            EracunDokument novi = new EracunDokument();
            novi.setIzlazni(true);
            novi.setElectronicId(electronicId);
            novi.setGodina(god);
            novi.setPosrednik(cfg.posrednikId());
            novi.setPosrednikNaziv(PosrednikType.fromId(cfg.posrednikId()).getCode());
            EracunDokumentDao.insertNoviDokument(conn, novi);
            centar = novi;
        }

        centar.setPutanjaXml(filePath.toString());
        centar.setDatumZadnjegStatusa(now);
        dokumentDao.updateAll(conn, centar);

        logger.info("Ažuriran eracun_dokument.putanja_xml i datum_zadnjeg_statusa za EID=" + electronicId);
        return new ReceiveResult(resp.xmlContent, filePath);
    }

    private static String toIsoLocalDateTime(Timestamp ts) {
        if (ts == null) return null;
        return ts.toLocalDateTime().withNano(0).toString(); // "YYYY-MM-DDTHH:MM:SS"
    }

    private static Timestamp parseTimestamp(String dateTimeStr) {
        if (dateTimeStr == null || dateTimeStr.trim().isEmpty()) {
            return null;
        }
        try {
            // ISO-8601 s offsetom (npr. ...+01:00 ili Z)
            return Timestamp.from(OffsetDateTime.parse(dateTimeStr).toInstant());
        } catch (Exception ignore) {
        }
        try {
            // Bez offseta; odreži milisekunde ako postoje
            String norm = dateTimeStr.replace('T', ' ');
            if (norm.length() > 19) norm = norm.substring(0, 19);
            return Timestamp.valueOf(norm);
        } catch (IllegalArgumentException e) {
            logger.error(new Functions().logging(e));
            return null;
        }
    }

    private static String str(IdentifierType t) { return t != null ? t.getValue() : null; }
    private static String str(NameType t)       { return t != null ? t.getValue() : null; }
    private static String str(TextType t)       { return t != null ? t.getValue() : null; }
    private static String str(CodeType t)       { return t != null ? t.getValue() : null; }
    private static String str(IDType t)         { return t != null ? t.getValue() : null; }
    private static String str(CurrencyCodeType t){ return t != null ? t.getValue() : null; }

    private static BigDecimal decAny(Object amountLike) {
        if (amountLike == null) return null;
        try {
            // svi UBL CBC amount tipovi imaju getValue(): BigDecimal
            java.lang.reflect.Method m = amountLike.getClass().getMethod("getValue");
            Object v = m.invoke(amountLike);
            return (v instanceof BigDecimal) ? (BigDecimal) v : null;
        } catch (Exception e) {
            return null;
        }
    }

    private static String joinNotes(java.util.List<NoteType> notes) {
        if (notes == null || notes.isEmpty()) return null;
        java.util.List<String> parts = new ArrayList<>();
        for (NoteType n : notes) {
            String v = str(n);
            if (v != null && !v.trim().isEmpty()) parts.add(v.trim());
        }
        return parts.isEmpty() ? null : String.join(" | ", parts);
    }

    public static InvoiceType unmarshalInvoice(String xml) throws JAXBException {
        JAXBContext jc = JAXBContext.newInstance(InvoiceType.class);
        Unmarshaller um = jc.createUnmarshaller();
        JAXBElement<InvoiceType> je = um.unmarshal(new StreamSource(new StringReader(xml)), InvoiceType.class);
        InvoiceType inv = je.getValue();
        return inv;
    }

    private static java.sql.Date sqlDate(DateType dt) {
        if (dt == null || dt.getValue() == null) return null;
        try {
            return java.sql.Date.valueOf(
                    dt.getValue().toGregorianCalendar().toZonedDateTime().toLocalDate()
            );
        } catch (Exception e) {
            return null;
        }
    }

    public void persistOutgoingFromXml(long electronicId,
                                       String xmlContent,
                                       String putanjaXml,
                                       int posrednik,
                                       String posrednikNaziv,
                                       Connection conn) throws Exception {
        InvoiceType inv = unmarshalInvoice(xmlContent);

        String broj = str(inv.getID());
        java.sql.Date issueDate = sqlDate(inv.getIssueDate());
        Integer godina = (issueDate != null) ? issueDate.toLocalDate().getYear() : null;

        String sql = "INSERT INTO eracun_dokument (" +
                "izlazni,godina,posrednik,posrednik_naziv,tip_dokumenta_naziv,broj,putanja_xml,electronic_id" +
                ") VALUES (?,?,?,?,?,?,?,?) " +
                "ON DUPLICATE KEY UPDATE " +
                "godina=VALUES(godina), posrednik=VALUES(posrednik), posrednik_naziv=VALUES(posrednik_naziv), " +
                "tip_dokumenta_naziv=VALUES(tip_dokumenta_naziv), broj=VALUES(broj), putanja_xml=VALUES(putanja_xml)";

        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setBoolean(1, true); // izlazni
            if (godina != null) ps.setInt(2, godina); else ps.setNull(2, Types.INTEGER);
            ps.setInt(3, posrednik);
            ps.setString(4, posrednikNaziv);
            ps.setString(5, "Invoice"); // naziv tipa; po potrebi mapiraj
            ps.setString(6, broj);
            ps.setString(7, putanjaXml);
            ps.setLong(8, electronicId);
            ps.executeUpdate();
        }
        logger.info("Dodano/azurirano u eracun_dokument (izlazni), ElectronicId=" + electronicId);
    }

    public void persistIncomingFromXml(long electronicId,
                                       String xmlContent,
                                       String putanjaXml,
                                       int posrednik,
                                       String posrednikNaziv,
                                       Connection conn) throws Exception {
        InvoiceType inv = unmarshalInvoice(xmlContent);

        // --- Header/overview ---
        String customizationId  = str(inv.getCustomizationID());
        String profileId        = str(inv.getProfileID());
        String invoiceId        = str(inv.getID());
        String documentTypeCode = str(inv.getInvoiceTypeCode());
        String documentTypeName = null;
        try {
            MerDocumentType mdt = MerDocumentType.fromUblCode(documentTypeCode);
            if (mdt != null) documentTypeName = mdt.getNaziv();
        } catch (Throwable ignore) { /* best-effort */ }
        String valuta           = str(inv.getDocumentCurrencyCode());

        java.sql.Date issueDate = sqlDate(inv.getIssueDate());
        java.sql.Date dueDate   = sqlDate(inv.getDueDate());
        java.sql.Date taxPoint  = sqlDate(inv.getTaxPointDate());
        Integer godina          = (issueDate != null) ? issueDate.toLocalDate().getYear() : null;

        String napomena         = joinNotes(inv.getNote());

        // Supplier
        PartyType sParty = inv.getAccountingSupplierParty() != null ? inv.getAccountingSupplierParty().getParty() : null;
        String supOib    = (sParty != null && sParty.getPartyLegalEntity() != null && !sParty.getPartyLegalEntity().isEmpty())
                ? str(sParty.getPartyLegalEntity().get(0).getCompanyID()) : null;
        String supName   = (sParty != null && sParty.getPartyLegalEntity() != null && !sParty.getPartyLegalEntity().isEmpty())
                ? str(sParty.getPartyLegalEntity().get(0).getRegistrationName())
                : (sParty != null && sParty.getPartyName() != null && !sParty.getPartyName().isEmpty() ? str(sParty.getPartyName().get(0).getName()) : null);
        String supEndpoint = (sParty != null) ? str(sParty.getEndpointID()) : null;
        AddressType sAddr = (sParty != null) ? sParty.getPostalAddress() : null;
        String supStreet = (sAddr != null) ? str(sAddr.getStreetName()) : null;
        String supCity   = (sAddr != null) ? str(sAddr.getCityName()) : null;
        String supZip    = (sAddr != null) ? str(sAddr.getPostalZone()) : null;
        String supCtry   = (sAddr != null && sAddr.getCountry()!=null) ? str(sAddr.getCountry().getIdentificationCode()) : null;

        // Customer
        PartyType cParty = inv.getAccountingCustomerParty() != null ? inv.getAccountingCustomerParty().getParty() : null;
        String cusOib    = (cParty != null && cParty.getPartyLegalEntity() != null && !cParty.getPartyLegalEntity().isEmpty())
                ? str(cParty.getPartyLegalEntity().get(0).getCompanyID()) : null;
        String cusName   = (cParty != null && cParty.getPartyLegalEntity() != null && !cParty.getPartyLegalEntity().isEmpty())
                ? str(cParty.getPartyLegalEntity().get(0).getRegistrationName())
                : (cParty != null && cParty.getPartyName() != null && !cParty.getPartyName().isEmpty() ? str(cParty.getPartyName().get(0).getName()) : null);
        String cusEndpoint = (cParty != null) ? str(cParty.getEndpointID()) : null;
        AddressType cAddr = (cParty != null) ? cParty.getPostalAddress() : null;
        String cusStreet = (cAddr != null) ? str(cAddr.getStreetName()) : null;
        String cusCity   = (cAddr != null) ? str(cAddr.getCityName()) : null;
        String cusZip    = (cAddr != null) ? str(cAddr.getPostalZone()) : null;
        String cusCtry   = (cAddr != null && cAddr.getCountry()!=null) ? str(cAddr.getCountry().getIdentificationCode()) : null;

        // Totals
        BigDecimal totalTaxAmount       = (inv.getTaxTotal()!=null && !inv.getTaxTotal().isEmpty()) ? decAny(inv.getTaxTotal().get(0).getTaxAmount()) : null;
        BigDecimal lineExtensionAmount  = (inv.getLegalMonetaryTotal()!=null) ? decAny(inv.getLegalMonetaryTotal().getLineExtensionAmount()) : null;
        BigDecimal taxExclusiveAmount   = (inv.getLegalMonetaryTotal()!=null) ? decAny(inv.getLegalMonetaryTotal().getTaxExclusiveAmount()) : null;
        BigDecimal taxInclusiveAmount   = (inv.getLegalMonetaryTotal()!=null) ? decAny(inv.getLegalMonetaryTotal().getTaxInclusiveAmount()) : null;
        BigDecimal allowanceTotalAmount = (inv.getLegalMonetaryTotal()!=null) ? decAny(inv.getLegalMonetaryTotal().getAllowanceTotalAmount()) : null;
        BigDecimal chargeTotalAmount    = (inv.getLegalMonetaryTotal()!=null) ? decAny(inv.getLegalMonetaryTotal().getChargeTotalAmount()) : null;
        BigDecimal prepaidAmount        = (inv.getLegalMonetaryTotal()!=null) ? decAny(inv.getLegalMonetaryTotal().getPrepaidAmount()) : null;
        BigDecimal roundingAmount       = (inv.getLegalMonetaryTotal()!=null) ? decAny(inv.getLegalMonetaryTotal().getPayableRoundingAmount()) : null;
        BigDecimal payableAmount        = (inv.getLegalMonetaryTotal()!=null) ? decAny(inv.getLegalMonetaryTotal().getPayableAmount()) : null;
        String headerTaxSchemeId        = (inv.getTaxTotal()!=null && !inv.getTaxTotal().isEmpty()
                && inv.getTaxTotal().get(0).getTaxSubtotal()!=null
                && !inv.getTaxTotal().get(0).getTaxSubtotal().isEmpty()
                && inv.getTaxTotal().get(0).getTaxSubtotal().get(0).getTaxCategory()!=null
                && inv.getTaxTotal().get(0).getTaxSubtotal().get(0).getTaxCategory().getTaxScheme()!=null)
                ? str(inv.getTaxTotal().get(0).getTaxSubtotal().get(0).getTaxCategory().getTaxScheme().getID()) : null;

        // Payment means
        int pmCount = (inv.getPaymentMeans()!=null) ? inv.getPaymentMeans().size() : 0;
        String paymentMeansCode = null, paymentMeansName = null, payeeAccountId = null;
        if (pmCount >= 1) {
            PaymentMeansType pm = inv.getPaymentMeans().get(0);
            paymentMeansCode = str(pm.getPaymentMeansCode());
            paymentMeansName = NacinPlacanjaCode.fromCode(paymentMeansCode).getNaziv();
            payeeAccountId   = (pm.getPayeeFinancialAccount()!=null) ? str(pm.getPayeeFinancialAccount().getID()) : null;
        }
        if (pmCount > 1) paymentMeansName = "kombinirano";

        // --- 1) eracuni_ulazni ---
        String insUl = "INSERT INTO eracuni_ulazni (" +
                "electronic_id, godina, invoice_id, profile_id, customization_id, document_type_code, document_type_name, valuta, " +
                "issue_date, due_date, tax_point_date, " +
                "dobavljac_oib, dobavljac_naziv, dobavljac_endpoint, dobavljac_adresa, dobavljac_grad, dobavljac_postanski, dobavljac_drzava, " +
                "kupac_oib, kupac_naziv, kupac_endpoint, kupac_adresa, kupac_grad, kupac_postanski, kupac_drzava, " +
                "total_tax_amount, line_extension_amount, tax_exclusive_amount, tax_inclusive_amount, allowance_total_amount, charge_total_amount, " +
                "prepaid_amount, payable_rounding_amount, payable_amount, header_tax_scheme_id, payment_means_code, payment_means_name, payee_account_id, " +
                "napomena, putanja_xml" +
                ") VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) " +
                "ON DUPLICATE KEY UPDATE " +
                "godina = VALUES(godina), invoice_id=VALUES(invoice_id), profile_id=VALUES(profile_id), customization_id=VALUES(customization_id), " +
                "document_type_code=VALUES(document_type_code), document_type_name=VALUES(document_type_name), valuta=VALUES(valuta), " +
                "issue_date=VALUES(issue_date), due_date=VALUES(due_date), tax_point_date=VALUES(tax_point_date), " +
                "dobavljac_oib=VALUES(dobavljac_oib), dobavljac_naziv=VALUES(dobavljac_naziv), dobavljac_endpoint=VALUES(dobavljac_endpoint), " +
                "dobavljac_adresa=VALUES(dobavljac_adresa), dobavljac_grad=VALUES(dobavljac_grad), dobavljac_postanski=VALUES(dobavljac_postanski), dobavljac_drzava=VALUES(dobavljac_drzava), " +
                "kupac_oib=VALUES(kupac_oib), kupac_naziv=VALUES(kupac_naziv), kupac_endpoint=VALUES(kupac_endpoint), kupac_adresa=VALUES(kupac_adresa), kupac_grad=VALUES(kupac_grad), kupac_postanski=VALUES(kupac_postanski), kupac_drzava=VALUES(kupac_drzava), " +
                "total_tax_amount=VALUES(total_tax_amount), line_extension_amount=VALUES(line_extension_amount), tax_exclusive_amount=VALUES(tax_exclusive_amount), tax_inclusive_amount=VALUES(tax_inclusive_amount), allowance_total_amount=VALUES(allowance_total_amount), charge_total_amount=VALUES(charge_total_amount), " +
                "prepaid_amount=VALUES(prepaid_amount), payable_rounding_amount=VALUES(payable_rounding_amount), payable_amount=VALUES(payable_amount), header_tax_scheme_id=VALUES(header_tax_scheme_id), " +
                "payment_means_code=VALUES(payment_means_code), payment_means_name=VALUES(payment_means_name), payee_account_id=VALUES(payee_account_id), " +
                "napomena=VALUES(napomena), putanja_xml=VALUES(putanja_xml)";

        try (PreparedStatement ps = conn.prepareStatement(insUl)) {
            int i=1;
            ps.setLong(i++, electronicId);
            if (godina != null) ps.setInt(i++, godina); else ps.setNull(i++, Types.INTEGER);
            ps.setString(i++, invoiceId);
            ps.setString(i++, profileId);
            ps.setString(i++, customizationId);
            ps.setString(i++, documentTypeCode);
            ps.setString(i++, documentTypeName);
            ps.setString(i++, valuta);

            if (issueDate != null) ps.setDate(i++, issueDate); else ps.setNull(i++, Types.DATE);
            if (dueDate != null) ps.setDate(i++, dueDate); else ps.setNull(i++, Types.DATE);
            if (taxPoint != null) ps.setDate(i++, taxPoint); else ps.setNull(i++, Types.DATE);

            ps.setString(i++, supOib);
            ps.setString(i++, supName);
            ps.setString(i++, supEndpoint);
            ps.setString(i++, supStreet);
            ps.setString(i++, supCity);
            ps.setString(i++, supZip);
            ps.setString(i++, supCtry);

            ps.setString(i++, cusOib);
            ps.setString(i++, cusName);
            ps.setString(i++, cusEndpoint);
            ps.setString(i++, cusStreet);
            ps.setString(i++, cusCity);
            ps.setString(i++, cusZip);
            ps.setString(i++, cusCtry);

            if (totalTaxAmount != null) ps.setBigDecimal(i++, totalTaxAmount); else ps.setNull(i++, Types.DECIMAL);
            if (lineExtensionAmount != null) ps.setBigDecimal(i++, lineExtensionAmount); else ps.setNull(i++, Types.DECIMAL);
            if (taxExclusiveAmount != null) ps.setBigDecimal(i++, taxExclusiveAmount); else ps.setNull(i++, Types.DECIMAL);
            if (taxInclusiveAmount != null) ps.setBigDecimal(i++, taxInclusiveAmount); else ps.setNull(i++, Types.DECIMAL);
            if (allowanceTotalAmount != null) ps.setBigDecimal(i++, allowanceTotalAmount); else ps.setNull(i++, Types.DECIMAL);
            if (chargeTotalAmount != null) ps.setBigDecimal(i++, chargeTotalAmount); else ps.setNull(i++, Types.DECIMAL);
            if (prepaidAmount != null) ps.setBigDecimal(i++, prepaidAmount); else ps.setNull(i++, Types.DECIMAL);
            if (roundingAmount != null) ps.setBigDecimal(i++, roundingAmount); else ps.setNull(i++, Types.DECIMAL);
            if (payableAmount != null) ps.setBigDecimal(i++, payableAmount); else ps.setNull(i++, Types.DECIMAL);
            ps.setString(i++, headerTaxSchemeId);

            ps.setString(i++, paymentMeansCode);
            ps.setString(i++, paymentMeansName);
            ps.setString(i++, payeeAccountId);

            ps.setString(i++, napomena);
            ps.setString(i++, putanjaXml);

            ps.executeUpdate();
        }

        // --- 2) stavke ---
        try (PreparedStatement del = conn.prepareStatement("DELETE FROM eracuni_ulazni_stavke WHERE electronic_id=?")) {
            del.setLong(1, electronicId);
            del.executeUpdate();
        }
        String insSt = "INSERT INTO eracuni_ulazni_stavke (" +
                "electronic_id, redni_broj, naziv_artikla, sifra_artikla, " +
                "kolicina, jedinica, cijena, osnovna_kolicina, osnovica, " +
                "porez_id, porez_postotak, tax_scheme_id" +
                ") VALUES (?,?,?,?,?,?,?,?,?,?,?,?)";

        try (PreparedStatement ps = conn.prepareStatement(insSt)) {
            for (InvoiceLineType line : inv.getInvoiceLine()) {
                Integer rb = null;
                try { rb = (line.getID()!=null) ? Integer.valueOf(line.getID().getValue()) : null; } catch (Exception ignore) {}

                String naziv = null;
                if (line.getItem() != null && line.getItem().getName() != null) {
                    naziv = str(line.getItem().getName()); // NameType → String
                }
                String sifra = (line.getItem()!=null && line.getItem().getSellersItemIdentification()!=null)
                        ? str(line.getItem().getSellersItemIdentification().getID()) : null;

                BigDecimal kolicina = decAny(line.getInvoicedQuantity());

                String jedinicaCode = line.getInvoicedQuantity() != null ? line.getInvoicedQuantity().getUnitCode() : null;
                String jedinicaNaziv = jedinicaCode;
                if (jedinicaCode != null) jedinicaNaziv = UnitOfMeasureCode.fromCode(jedinicaCode).getOznaka();

                String jedinica = (line.getInvoicedQuantity()!=null) ? jedinicaNaziv : null;
                BigDecimal cijena = (line.getPrice()!=null) ? decAny(line.getPrice().getPriceAmount()) : null;
                BigDecimal osnovnaKolicina = (line.getPrice()!=null) ? decAny(line.getPrice().getBaseQuantity()) : null;
                BigDecimal osnovica = decAny(line.getLineExtensionAmount());

                String porezId = null, taxSchemeId = null;
                BigDecimal porezPostotak = null;

                if (line.getItem() != null && line.getItem().getClassifiedTaxCategory() != null) {
                    java.util.List<oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_2.TaxCategoryType> cats =
                            line.getItem().getClassifiedTaxCategory();

                    // 1) tražimo prvi s ID = "S" (standardna stopa PDV-a)
                    for (oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_2.TaxCategoryType cat : cats) {
                        String cid = str(cat.getID());
                        if ("S".equals(cid)) {
                            porezId = cid;
                            porezPostotak = decAny(cat.getPercent());
                            if (cat.getTaxScheme() != null) {
                                taxSchemeId = str(cat.getTaxScheme().getID());
                            }
                            break;
                        }
                    }
                    // 2) fallback: ako nema "S", uzmi prvi element (ako postoji)
                    if (porezId == null && !cats.isEmpty()) {
                        oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_2.TaxCategoryType cat = cats.get(0);
                        porezId = str(cat.getID());
                        porezPostotak = decAny(cat.getPercent());
                        if (cat.getTaxScheme() != null) {
                            taxSchemeId = str(cat.getTaxScheme().getID());
                        }
                    }
                }

                int i=1;
                ps.setLong(i++, electronicId);
                if (rb != null) ps.setInt(i++, rb); else ps.setNull(i++, java.sql.Types.INTEGER);
                ps.setString(i++, naziv);
                ps.setString(i++, sifra);
                if (kolicina != null) ps.setBigDecimal(i++, kolicina); else ps.setNull(i++, java.sql.Types.DECIMAL);
                ps.setString(i++, jedinica);
                if (cijena != null) ps.setBigDecimal(i++, cijena); else ps.setNull(i++, java.sql.Types.DECIMAL);
                if (osnovnaKolicina != null) ps.setBigDecimal(i++, osnovnaKolicina); else ps.setNull(i++, java.sql.Types.DECIMAL);
                if (osnovica != null) ps.setBigDecimal(i++, osnovica); else ps.setNull(i++, java.sql.Types.DECIMAL);
                ps.setString(i++, porezId);
                if (porezPostotak != null) ps.setBigDecimal(i++, porezPostotak); else ps.setNull(i++, java.sql.Types.DECIMAL);
                ps.setString(i++, taxSchemeId);

                ps.addBatch();
            }
            ps.executeBatch();
        }

        // dopuni centralni zapis (IN) u eracun_dokument ---
        // koristi postojeći DAO iz ove instance (bez new)
        EracunDokument centar = dokumentDao.findByElectronicId(electronicId, false, conn);

        if (centar == null) {
            // robusni fallback: ako stub nije kreiran u QUERY koraku, kreiraj minimalni IN zapis
            EracunDokument novi = new EracunDokument();
            novi.setIzlazni(false);
            novi.setVrstaDokumenta(29);
            novi.setVrstaDokumentaNaziv("Ulazni eračun");
            novi.setElectronicId(electronicId);
            novi.setPosrednik(posrednik);
            novi.setPosrednikNaziv(posrednikNaziv);
            novi.setPutanjaXml(putanjaXml);
            novi.setLokalniStatusId(EracunStatus.ZAPRIMLJEN.getId());
            novi.setLokalniStatusNaziv(EracunStatus.ZAPRIMLJEN.getNaziv());
            novi.setDatumZadnjegStatusa(new java.sql.Timestamp(System.currentTimeMillis()));
            EracunDokumentDao.insertNoviDokument(conn, novi);
            centar = novi;
        }

        // dopuni polja koja znamo iz XML-a (ne diramo statuse ako već postoje)
        centar.setDocumentNr(invoiceId);                              // <cbc:ID>
        centar.setIssueDate(combineDateTime(inv));                    // IssueDate + "Vrijeme izdavanja" (ako postoji)
        if (issueDate != null) {
            centar.setGodina(issueDate.toLocalDate().getYear());      // godina iz datuma izdavanja
        }
        centar.setTipDokumenta(str(inv.getInvoiceTypeCode()));
        centar.setTipDokumentaNaziv(documentTypeName);
        centar.setPartnerBusinessNumber(supOib);                      // dobavljač OIB
        centar.setPartnerBusinessName(supName);                       // dobavljač naziv
        centar.setPutanjaXml(putanjaXml);                             // gdje je XML spremljen
        centar.setDatumZadnjegStatusa(new java.sql.Timestamp(System.currentTimeMillis()));

        dokumentDao.updateAll(conn, centar);
        logger.info("Ulazni eRačun spremljen (JAXB), ElectronicId=" + electronicId);
    }

    // Jedna idempotentna iteracija provjere INBOX-a na MER-u.
    // - Čita zadnji marker (zadnji_sent) iz eracun_sync_state za smjer "INBOX"
    // - QueryInbox(FROM=marker) u processQueryInboxResponse(...)
    // - Ažurira marker na "sada" + zadnji_eid (max viđeni)
    public void pollInboxOnce() {
        Connection conn = null;
        try {
            conn = new __Pool(null).getConnection(); // tvoj uobičajeni pool/način dobave konekcije

            // 1) Učitaj state
            EracunSyncState state =
                    syncStateDao.getState(cfg.posrednikId(), "INBOX", conn);

            String fromIso = (state != null) ? toIsoLocalDateTime(state.getZadnjiSent()) : null;

            // 2) MER queryInbox (filtriramo po from-datumu ako ga imamo)
            QueryInboxResponse resp =
                    merClient.queryInbox(
                            null,   // filter: "Undelivered" ili null (za sad null)
                            null,   // electronicId
                            null,   // statusId (30/40 ili null)
                            fromIso,
                            null
                    ); // V1 Query inbox outbox koristi ISO datume. :contentReference[oaicite:2]{index=2}

            // 3) Obradi i persistiraj u tvoju tablicu eracuni_ulazni + log
            processQueryInboxResponse(resp, conn);

            // 4) Izračun markera
            Timestamp now = new Timestamp(System.currentTimeMillis());
            Long maxEid = null;
            if (resp != null && resp.documents != null && !resp.documents.isEmpty()) {
                for (InboxDocumentHeader h : resp.documents) {
                    long eid = h.getElectronicId();
                    if (maxEid == null || eid > maxEid.longValue()) maxEid = eid;
                }
            }

            // 5) Upsert state (zadnji_sent=now, zadnji_eid=max viđeni)
            if (state == null) {
                syncStateDao.saveState(cfg.posrednikId(), "INBOX", now, maxEid, conn);
            } else {
                state.setZadnjiSent(now);
                state.setZadnjiEid(maxEid);
                syncStateDao.saveState(state, conn);
            }
            logger.info("INBOX sync završio — ažuriran marker (from=" + fromIso + ", now=" + now + ").");

        } catch (EracunServiceUnavailableException ex) {
            // NE logiraj ovdje – pusti Schedulera da odluči (on će napraviti WARN + poruka u 'poruke')
            throw ex;
        } catch (Exception e) {
            // ovo su lokalne greške – zadržavamo ERROR tako da ode u centralu
            logger.error(new Functions().logging(e));
        } finally {
            if (conn != null) try { conn.close(); } catch (Exception ignore) {}
        }
    }

    // Jedna idempotentna iteracija provjere OUTBOX-a na MER-u.
    // - Čita zadnji marker (zadnji_sent) za smjer "OUTBOX"
    // - QueryOutbox(FROM=marker) to processQueryOutboxResponse(...)
    // - Ažurira marker na "sada"
    //
    public void pollOutboxStatusesOnce() {
        java.sql.Connection conn = null;
        try {
            conn = new __Pool(null).getConnection();

            // 1) Učitaj state
            EracunSyncState state =
                    syncStateDao.getState(cfg.posrednikId(), "OUTBOX", conn);

            String fromIso = (state != null) ? toIsoLocalDateTime(state.getZadnjiSent()) : null;

            // 2) MER queryOutbox (filtriramo po from-datumu)
            QueryOutboxResponse resp =
                    merClient.queryOutbox(
                            null,   // electronicId
                            null,   // statusId
                            null,   // year
                            null,   // number
                            fromIso,
                            null
                    );

            // 3) Obradi statuse i logiraj (već imaš metodu)
            processQueryOutboxResponse(resp, conn); // ažurira eracun_dokument + log

            // Obradi backlog siročadi iz baze (broj po rundi je u konfiguraciji)
            int pulled = receiveOutboxOrphans(conn);
            if (pulled > 0) {
                logger.info("Backlog receive: preuzeto siročadi=" + pulled);
            }
            // pokušava linkati siročad. (broj po rundi je u konfiguraciji)
            int[] res = linkOutboxOrphans(conn);
            logger.info("Link backlog: pokušano=" + res[0] + ", dovršeno=" + res[1]);

            // 4) Ažuriraj marker
            java.sql.Timestamp now = new java.sql.Timestamp(System.currentTimeMillis());
            Long maxEid = null;
            if (resp != null && resp.getDocumentHeaders() != null && !resp.getDocumentHeaders().isEmpty()) {
                for (OutboxDocumentHeader h : resp.getDocumentHeaders()) {
                    long eid = h.getElectronicId();
                    if (maxEid == null || eid > maxEid.longValue()) maxEid = eid;
                }
            }

            if (state == null) {
                syncStateDao.saveState(cfg.posrednikId(), "OUTBOX", now, maxEid, conn);
            } else {
                state.setZadnjiSent(now);
                state.setZadnjiEid(maxEid);
                syncStateDao.saveState(state, conn);
            }
            logger.info("OUTBOX sync završio — ažuriran marker (from=" + fromIso + ", now=" + now + ").");

        } catch (EracunServiceUnavailableException ex) {
            // NE logiraj ovdje – pusti Schedulera da odluči (on će napraviti WARN + poruka u 'poruke')
            throw ex;
        } catch (Exception e) {
            // ovo su lokalne greške – zadržavamo ERROR tako da ode u centralu
            logger.error(new Functions().logging(e));
        } finally {
            if (conn != null) try { conn.close(); } catch (Exception ignore) {}
        }
    }


	public void manualResyncForYear(int godina, boolean outbox, boolean inbox, SyncProgress progress) {
		Connection conn = null;
		try {
			progress.onPhase("Priprema konekcije");
			conn = new __Pool(null).getConnection();
			conn.setAutoCommit(false);

			java.time.LocalDate from = java.time.LocalDate.of(godina, 1, 1);
			java.time.LocalDate to   = java.time.LocalDate.of(godina, 12, 31);

			// 12 mjesečnih prozora — stabilno i pregledno
			java.time.LocalDate cursor = from;
			int totalWindows = 12;
			int windowIndex = 0;

			progress.onStart("Ručna sinkronizacija za " + godina, totalWindows);

			while (!cursor.isAfter(to)) {
				if (progress.isCancelled()) break;

				java.time.LocalDate windowStart = cursor.withDayOfMonth(1);
				java.time.LocalDate windowEnd   = cursor.withDayOfMonth(cursor.lengthOfMonth());
				if (windowEnd.isAfter(to)) windowEnd = to;

				// ISO prozori u sekundama (T00:00:00 → T23:59:59)
				String fromIso = windowStart.atStartOfDay().withNano(0).toString();
				String toIso   = windowEnd.atTime(java.time.LocalTime.of(23,59,59)).withNano(0).toString();

				windowIndex++;
				progress.onPhase((outbox ? "[OUT]" : "") + (inbox ? "[IN]" : "") +
						" prozor " + windowIndex + "/" + totalWindows + " (" + fromIso + " → " + toIso + ")");

				// OUTBOX
				if (outbox) {
					try {
						QueryOutboxResponse out = merClient.queryOutbox(null, null, null, null, fromIso, toIso);

						java.util.List<OutboxDocumentHeader> headers =
								(out != null && out.getDocumentHeaders() != null) ? out.getDocumentHeaders() : java.util.Collections.<OutboxDocumentHeader>emptyList();

						int done = 0;
						progress.onStart("Outbox " + fromIso + " → " + toIso, headers.size());
						for (OutboxDocumentHeader h : headers) {
							if (progress.isCancelled()) break;

							// Postojeća logika (siročad):
							EracunDokument postoji = dokumentDao.findByElectronicId(h.getElectronicId(), true, conn);
							if (postoji == null) {

								EracunDokument novi = fromHeader(h, conn, true);
								dokumentDao.insertNoviDokument(conn, novi);

								// Snapshot u log
								EracunDokumentLog snap = new EracunDokumentLog();
								snap.setIzlazni(true);
								snap.setGodina(novi.getGodina());
								snap.setPosrednik(novi.getPosrednik());
								snap.setVrstaDokumenta(novi.getVrstaDokumenta());
								snap.setOnu(novi.getOnu());
								snap.setOpp(novi.getOpp());
								snap.setBroj(novi.getBroj());
								snap.setElectronicId(novi.getElectronicId());
								snap.setLokalniStatusId(novi.getLokalniStatusId());
								snap.setLokalniStatusNaziv(novi.getLokalniStatusNaziv());
								snap.setTransportniStatusId(novi.getTransportniStatusId());
								snap.setTransportniStatusNaziv(novi.getTransportniStatusNaziv());
								snap.setProcesniStatusId(novi.getProcesniStatusId());
								snap.setProcesniStatusNaziv(novi.getProcesniStatusNaziv());
								snap.setDatumStatusa(novi.getDatumZadnjegStatusa());
								snap.setDatumPromjene(new java.sql.Timestamp(System.currentTimeMillis()));
								snap.setPorukaGreske(null);
								snap.setOpis("Bootstrap OUTBOX");
								snap.setPutanjaXml(novi.getPutanjaXml());
								snap.setIzvor("MANUAL_RESYNC");
								snap.setAkcija("SNAPSHOT");
								snap.setOibOperatera(null);
								logDao.insert(snap, conn);
							}

							done++;
							progress.onItem("OUTBOX EID=" + h.getElectronicId(), done, headers.size());

							// batch commit svakih 100 radi sigurnosti
							if (done % 100 == 0) {
								conn.commit();
							}
						}
						conn.commit();
					} catch (Exception ex) {
						logger.error(new Functions().logging(ex));
						progress.onError("Greška u outbox prozoru " + fromIso + " → " + toIso, ex);
						try { conn.rollback(); } catch (Exception ignore) {}
					}
				}

				// INBOX (po potrebi — ista filozofija, stavi stub s izlazni=false)
				if (inbox) {
					try {
						QueryInboxResponse in = merClient.queryInbox(null, null, null, fromIso, toIso);
						java.util.List<InboxDocumentHeader> headers =
								(in != null && in.documents != null) ? in.documents : java.util.Collections.<InboxDocumentHeader>emptyList();

						int done = 0;
						progress.onStart("Inbox " + fromIso + " → " + toIso, headers.size());
						for (InboxDocumentHeader h : headers) {
							if (progress.isCancelled()) break;

							EracunDokument postoji = dokumentDao.findByElectronicId(h.getElectronicId(), false, conn);
							if (postoji == null) {
								EracunDokument novi = new EracunDokument();
								novi.setIzlazni(false);
								novi.setGodina(godina);
								novi.setPosrednik(cfg.posrednikId());
								novi.setPosrednikNaziv(PosrednikType.fromId(cfg.posrednikId()).getCode());
								novi.setVrstaDokumenta(29);
								novi.setVrstaDokumentaNaziv("Ulazni eračun");
								novi.setTipDokumenta(String.valueOf(h.getDocumentTypeId()));
								novi.setTipDokumentaNaziv(h.getDocumentTypeName() != null ? h.getDocumentTypeName() : "");
								novi.setOnu(0);
								novi.setOpp(0);
								novi.setBroj(0);
								novi.setPutanjaXml(null);
								novi.setElectronicId(h.getElectronicId());

								// Postavi minimalni status i datume iz headera (ako postoje)
								novi.setLokalniStatusId(EracunStatus.ZAPRIMLJEN.getId());
								novi.setLokalniStatusNaziv(EracunStatus.ZAPRIMLJEN.getNaziv());
								novi.setSent(parseTimestamp(h.getSent()));
								novi.setDelivered(parseTimestamp(h.getDelivered()));
								novi.setDatumZadnjegStatusa(new java.sql.Timestamp(System.currentTimeMillis()));

								dokumentDao.insertNoviDokument(conn, novi);

								EracunDokumentLog snap = new EracunDokumentLog();
								snap.setIzlazni(false);
								snap.setGodina(novi.getGodina());
								snap.setPosrednik(novi.getPosrednik());
								snap.setVrstaDokumenta(novi.getVrstaDokumenta());
								snap.setOnu(novi.getOnu());
								snap.setOpp(novi.getOpp());
								snap.setBroj(novi.getBroj());
								snap.setElectronicId(novi.getElectronicId());
								snap.setOpis("Bootstrap INBOX");
								snap.setIzvor("MANUAL_RESYNC");
								snap.setAkcija("SNAPSHOT");
								snap.setDatumPromjene(new java.sql.Timestamp(System.currentTimeMillis()));
								logDao.insert(snap, conn);
							}

							done++;
							progress.onItem("INBOX EID=" + h.getElectronicId(), done, headers.size());
							if (done % 100 == 0) conn.commit();
						}
						conn.commit();
					} catch (Exception ex) {
						logger.error(new Functions().logging(ex));
						progress.onError("Greška u inbox prozoru " + fromIso + " → " + toIso, ex);
						try { conn.rollback(); } catch (Exception ignore) {}
					}
				}
				cursor = cursor.plusMonths(1);
				progress.onItem("Završena obrada prozora " + fromIso + " → " + toIso, windowIndex, totalWindows);
			}
			progress.onComplete();
		} catch (Exception ex) {
			logger.error(new Functions().logging(ex));
			progress.onError("Fatalna greška u manualResyncForYear", ex);
			try { if (conn != null) conn.rollback(); } catch (Exception ignore) {}
		} finally {
			if (conn != null) try { conn.close(); } catch (Exception ignore) {}
		}
	}

	private EracunDokument fromHeader(OutboxDocumentHeader header, Connection conn, boolean izlazni) {
		System.out.println("FROM HEADER");
		EracunDokument novi = new EracunDokument();
		novi.setIzlazni(izlazni);

		// transportni status
		novi.setTransportniStatusId(header.getStatusId());
		novi.setTransportniStatusNaziv(header.getStatusName());
		novi.setGodina(0); // dok ne preuzmemo XML ne znamo pouzdano

		novi.setPosrednik(cfg.posrednikId());
		novi.setPosrednikNaziv(PosrednikType.fromId(cfg.posrednikId()).getCode());
		if (izlazni) {
			novi.setVrstaDokumenta(0);
			novi.setVrstaDokumentaNaziv("Nepoznato");
		} else {
			novi.setVrstaDokumenta(29);
			novi.setVrstaDokumentaNaziv("Ulazni eračun");
		}

		// Sigurno mapiranje tipa dokumenta
		MerDocumentType mdt = null;
		try { mdt = MerDocumentType.fromId(header.getDocumentTypeId()); } catch (Throwable ignore) {}
		if (mdt != null) {
			novi.setTipDokumenta(mdt.getUblCode());
			novi.setTipDokumentaNaziv(mdt.getNaziv());
		} else {
			novi.setTipDokumenta(null);
			novi.setTipDokumentaNaziv(header.getDocumentTypeName());
		}

		novi.setOnu(0);
		novi.setOpp(0);
		novi.setBroj(0);

		// ➕ Pokušaj izvući broj/OPP/ONU iz DocumentNr (best-effort, ali ne postavljamo PRONADJEN prerano)
		try {
			System.out.println("DocumentNr:" + header.getDocumentNr());
			DocumentNrResolver.Result dr = DocumentNrResolver.resolve(conn, header.getDocumentNr());
			if (dr != null) {
				if (dr.broj != null) novi.setBroj(dr.broj);
				if (dr.oppId != null) novi.setOpp(dr.oppId);
				if (dr.onuId != null) novi.setOnu(dr.onuId);
				// Ako nešto nedostaje, označi kao BEZ_LINKA (ostavi za kasniji link prema XML-u)
				novi.setLokalniStatusId(EracunStatus.BEZ_LINKA.getId());
				novi.setLokalniStatusNaziv(EracunStatus.BEZ_LINKA.getNaziv());
			}
		} catch (Exception ex) {
			logger.error(new Functions().logging(ex));
		}

		novi.setPutanjaXml(null);
		novi.setElectronicId(header.getElectronicId());
		novi.setDocumentNr(header.getDocumentNr());
		novi.setDocumentTypeId(header.getDocumentTypeId());
		novi.setDocumentTypeName(header.getDocumentTypeName());
		novi.setPartnerBusinessNumber(header.getRecipientBusinessNumber());
		novi.setPartnerBusinessUnit(header.getRecipientBusinessUnit());
		novi.setPartnerBusinessName(header.getRecipientBusinessName());

		// Datumi iz headera
		novi.setCreated(parseTimestamp(header.getCreated()));
		novi.setUpdated(parseTimestamp(header.getUpdated()));
		novi.setSent(parseTimestamp(header.getSent()));
		novi.setDelivered(parseTimestamp(header.getDelivered()));
		novi.setIssueDate(parseTimestamp(header.getIssueDate()));

		// novi.setAdditionalDokumentStatusId(header.getAdditionalDokumentStatusId());
		novi.setRejectReason(header.getRejectReason());
		novi.setDatumKreiranja(parseTimestamp(header.getCreated()));
		novi.setDatumSlanja(parseTimestamp(header.getSent()));
		novi.setBrojSlanja(1);
		novi.setDatumZadnjegStatusa(parseTimestamp(header.getUpdated()));

		novi.setPorukaGreske(null);
		novi.setSyncToken(null);
		novi.setFiskaliziranStatus(MerFiscalizationStatus.UNKNOWN.getId());
		novi.setPlacenStatus(MerMarkPaidStatus.UNKNOWN.getId());
		novi.setOdbijenStatus(MerRejectStatus.UNKNOWN.getId());
		novi.setVizualiziran(false);
		return novi;
	}

	public boolean linkEracun(long electronicId, Connection conn,
							  EracunDokument eracun,
							  String xmlContent) throws SQLException {

		boolean kompletan = false;
		Timestamp now = new Timestamp(System.currentTimeMillis());

		if (eracun != null) {
			eracun.setDatumZadnjegStatusa(now);

			// Ako je BEZ_LINKA, pokušaj popuniti iz XML-a
			if (eracun.getLokalniStatusId() == EracunStatus.BEZ_LINKA.getId()) {
				try {
					InvoiceType invoice = unmarshalInvoice(xmlContent);

					// Datum/vrijeme izdavanja
					eracun.setIssueDate(combineDateTime(invoice));
					int godinaXml = 0;
					if (invoice.getIssueDate() != null && invoice.getIssueDate().getValue() != null) {
						godinaXml = invoice.getIssueDate().getValue().getYear();
					}

					// Broj/OPP/ONU iz <cbc:ID>
					String documentNr = (invoice.getID() != null) ? invoice.getID().getValue() : null;
					int onuXml = 0, oppXml = 0, brojXml = 0;
					if (documentNr != null) {
						DocumentNrResolver.Result parsed = DocumentNrResolver.resolve(conn, documentNr);
						if (parsed != null) {
							if (parsed.onuId != null) onuXml = parsed.onuId;
							if (parsed.oppId != null) oppXml = parsed.oppId;
							if (parsed.broj  != null) brojXml = parsed.broj;
						}
					}

					// Vrsta dokumenta iz UBL koda
					String ublCode = (invoice.getInvoiceTypeCode() != null)
							? invoice.getInvoiceTypeCode().getValue()
							: null;
					int vrstaXml = 0;
					if (ublCode != null) {
						try {
							vrstaXml = DokumentiVrsteDao.findIdByUbl(ublCode, conn);
						} catch (Exception ignore) {}
					}

					logger.debug("linkEracun: eid=" + electronicId
							+ " godina=" + godinaXml + " onu=" + onuXml + " opp=" + oppXml
							+ " broj=" + brojXml + " vrsta=" + vrstaXml);

					if (godinaXml > 0 && onuXml > 0 && oppXml > 0 && brojXml > 0 && vrstaXml > 0) {
						eracun.setGodina(godinaXml);
						eracun.setOnu(onuXml);
						eracun.setOpp(oppXml);
						eracun.setBroj(brojXml);
						eracun.setVrstaDokumenta(vrstaXml);
						eracun.setLokalniStatusId(EracunStatus.PRONADJEN.getId());
						eracun.setLokalniStatusNaziv(EracunStatus.PRONADJEN.getNaziv());
						kompletan = true;
						logger.info("Popunjeni svi podaci za BEZ_LINKA dokument (EID=" + electronicId + ")");
					} else {
						logger.warn("Nedostaju elementi poslovnog ključa – dokument ostaje BEZ_LINKA (EID=" + electronicId + ")");
					}
				} catch (Exception ex) {
					logger.error("Greška pri parsiranju XML-a za EID=" + electronicId, ex);
				}
			}
			dokumentDao.updateAll(conn, eracun);
			logger.debug("DB nakon updateAll: g=" + eracun.getGodina()
					+ ", onu=" + eracun.getOnu() + ", opp=" + eracun.getOpp()
					+ ", broj=" + eracun.getBroj() + ", vrsta=" + eracun.getVrstaDokumenta()
					+ ", lok=" + eracun.getLokalniStatusId());
			logger.info("Ažuriran eracun_dokument za EID=" + electronicId);
		}
		return kompletan;
	}

	public Timestamp combineDateTime(InvoiceType invoice) {
		if (invoice == null || invoice.getIssueDate() == null || invoice.getIssueDate().getValue() == null) return null;

		// 1. Datum iz IssueDate
		XMLGregorianCalendar issue = invoice.getIssueDate().getValue();
		int year = issue.getYear();
		int month = issue.getMonth();
		int day = issue.getDay();

		// 2. Vrijeme iz note (regex, tolerantno na razmake/tekst)
		LocalTime time = LocalTime.MIDNIGHT;
		List<NoteType> notes = invoice.getNote();
		if (notes != null) {
			Pattern p = Pattern.compile("Vrijeme izdavanja:\\s*(\\d{2}:\\d{2}:\\d{2})");
			for (NoteType note : notes) {
				String value = note.getValue();
				if (value == null) continue;
				Matcher m = p.matcher(value);
				if (m.find()) {
					try { time = LocalTime.parse(m.group(1)); } catch (Exception ignore) {}
					break;
				}
			}
		}

		// 3. Kombiniraj
		LocalDateTime dateTime = LocalDateTime.of(year, month, day, time.getHour(), time.getMinute(), time.getSecond());
		return Timestamp.valueOf(dateTime);
	}

	/** Preuzme do maxCount siročadi (izlazni, bez XML-a) i pokuša RECEIVE+link. Vraća broj uspjeha. */
	private int receiveOutboxOrphans(Connection conn) {
		int maxCount = cfg.orphansPerSession();
		int done = 0;
		final String sql =
				"SELECT electronic_id, tip_dokumenta, tip_dokumenta_naziv, vrsta_dokumenta, godina, document_nr " +
				"FROM eracun_dokument " +
				"WHERE izlazni=1 " +
				"  AND (putanja_xml IS NULL OR putanja_xml='') " +
				"  AND (lokalni_status_id IS NULL OR lokalni_status_id=?) " + // BEZ_LINKA ili null
				"ORDER BY (datum_zadnjeg_statusa IS NULL), datum_zadnjeg_statusa ASC, electronic_id ASC " +
				"LIMIT ?";

		try (PreparedStatement ps = conn.prepareStatement(sql)) {
			ps.setInt(1, EracunStatus.BEZ_LINKA.getId());
			ps.setInt(2, Math.max(1, maxCount));

			try (ResultSet rs = ps.executeQuery()) {
				while (rs.next()) {
					long electronicId  = rs.getLong("electronic_id");
					String tip         = rs.getString("tip_dokumenta");       // UBL code (npr. "380")
					String tipNaz      = rs.getString("tip_dokumenta_naziv"); // npr. "Račun"
					int vrsta          = rs.getInt("vrsta_dokumenta");        // 0 dok ne znaš, ok
					Integer godina     = rs.getObject("godina") != null ? rs.getInt("godina") : null;
					String broj        = rs.getString("document_nr");

					try {
						receiveInvoice(this.merClient,
								electronicId,
								PosrednikType.fromId(cfg.posrednikId()).getCode(),
								"izlazni",
								PodaciFirmaModel.loadOib(),
								tip != null ? tip : "000",
								tipNaz != null ? tipNaz : "Račun",
								String.valueOf(vrsta), // "0" dok ne znaš
								(godina != null && godina > 0) ? String.valueOf(godina) : "0",
								broj,
								null, // issueDateTime
								Paths.get(cfg.baseDir()),
								conn
						);
						done++;
					} catch (Exception ex) {
						logger.error(new Functions().logging(ex));
					}
				}
			}
		} catch (SQLException ex) {
			logger.error(new Functions().logging(ex));
		}
		return done;
	}

	/*
	 * Prođe izlazne orphane koji IMAJU XML, ali NISU kompletno linkani,
	 * te pokuša linkati koristeći spremljeni XML.
	 * Vraća [broj_pokušaja, broj_dovršenih].
	 */
	public int[] linkOutboxOrphans(Connection conn) {
		int attempted = 0;
		int completed = 0;

		final String sql =
				"SELECT electronic_id, putanja_xml, tip_dokumenta, tip_dokumenta_naziv, vrsta_dokumenta, document_nr " +
				"FROM eracun_dokument " +
				"WHERE izlazni=1 " +
				"  AND putanja_xml IS NOT NULL AND putanja_xml<>'' " +
				"  AND (lokalni_status_id IS NULL OR lokalni_status_id=?) " + // BEZ_LINKA ili null
				"  AND (godina IS NULL OR godina=0 OR onu IS NULL OR onu=0 OR opp IS NULL OR opp=0 OR broj IS NULL OR broj=0 OR vrsta_dokumenta IS NULL OR vrsta_dokumenta=0) " +
				"ORDER BY RAND(?) " +
				"LIMIT ?";

		try (PreparedStatement ps = conn.prepareStatement(sql)) {
			ps.setInt(1, EracunStatus.BEZ_LINKA.getId());
			ps.setDouble(2, ThreadLocalRandom.current().nextDouble()); // seed
			ps.setInt(3, Math.max(1, cfg.relinksPerSession()));
			try (ResultSet rs = ps.executeQuery()) {
				while (rs.next()) {
					long eid = rs.getLong("electronic_id");
					String path = rs.getString("putanja_xml");

					attempted++;

					try {
						EracunDokument doc = dokumentDao.findByElectronicId(eid, true, conn);
						if (doc == null) continue;
						if (path == null || path.trim().isEmpty()) continue;

						Path p = Paths.get(path);
						if (!Files.exists(p)) {
							logger.warn("XML datoteka ne postoji za EID=" + eid + " → " + path);
							continue;
						}

						String xml = new String(Files.readAllBytes(p), StandardCharsets.UTF_8);

						// Parse XML i pozovi linkEracun
						InvoiceType invoice = null;
						try { invoice = unmarshalInvoice(xml); } catch (Exception ignore) {}
						Timestamp ts = (invoice != null) ? combineDateTime(invoice) : null;
						LocalDateTime issueLdt = (ts != null) ? ts.toLocalDateTime() : LocalDateTime.now();
						String documentNr = (invoice != null && invoice.getID()!=null) ? invoice.getID().getValue() : doc.getDocumentNr();

						boolean complete = linkEracun(eid, conn, doc, xml);

						// Ako je postao kompletan, premjesti/preimenuj XML
						if (complete) {
							logger.info("orphan postao kompletan. EID=" + eid);
							try {
								logger.info("linkOutboxOrphans oib=" + PodaciFirmaModel.loadOib() + ", tip=" + doc.getTipDokumenta() + ", tipNaziv=" + doc.getTipDokumentaNaziv()
										+ ", vrsta=" + doc.getVrstaDokumenta() + ", godina=" + doc.getGodina() + ", nr=" + (doc.getDocumentNr() != null ? doc.getDocumentNr() : documentNr) + ", ldt=" + issueLdt);

								Path finalPath = DokumentFileHelper.getFilePath(
										"izlazni",
										PodaciFirmaModel.loadOib(),
										doc.getTipDokumenta(),
										doc.getTipDokumentaNaziv(),
										doc.getVrstaDokumenta(),
										doc.getGodina(),
										(doc.getDocumentNr() != null ? doc.getDocumentNr() : documentNr),
										issueLdt,
										doc.getElectronicId()
								);

								// Backfill procesnog statusa (ako želiš odmah)
								backfillOutboxProcessStatusFor(eid, conn);

								// Premjesti ako je drugačija lokacija
								moveIfNeeded(Paths.get(doc.getPutanjaXml()), finalPath);
								doc.setPutanjaXml(finalPath.toString());
								dokumentDao.updateAll(conn, doc);

								// Log: LINK
								EracunDokumentLog log = new EracunDokumentLog();
								log.setIzlazni(true);
								log.setGodina(doc.getGodina());
								log.setPosrednik(doc.getPosrednik());
								log.setVrstaDokumenta(doc.getVrstaDokumenta());
								log.setOnu(doc.getOnu());
								log.setOpp(doc.getOpp());
								log.setBroj(doc.getBroj());
								log.setElectronicId(doc.getElectronicId());
								java.sql.Timestamp now = new java.sql.Timestamp(System.currentTimeMillis());
								log.setDatumStatusa(now);
								log.setDatumPromjene(now);
								log.setOpis("Automatsko linkanje (outbox backlog)");
								log.setAkcija("LINK");
								log.setIzvor(PosrednikType.fromId(cfg.posrednikId()).getCode());
								log.setPutanjaXml(doc.getPutanjaXml());
								logDao.insert(log, conn);

								completed++;
							} catch (Exception mx) {
								logger.error(new Functions().logging(mx));
							}
						}
					} catch (Exception ex) {
						logger.error(new Functions().logging(ex));
					}
				}
			}
		} catch (SQLException ex) {
			logger.error(new Functions().logging(ex));
		}

		return new int[]{attempted, completed};
	}

	/** Premjesti datoteku ako je cilj drugačiji; kreira direktorije; fallback ako ATOMIC_MOVE nije podržan. */
	private void moveIfNeeded(Path src, Path dst) throws IOException {
		if (src == null || dst == null) return;
		logger.info("moveIfNeeded src=" + src.toString() + " dst=" + dst.toString());
		if (Objects.equals(src.toAbsolutePath().normalize().toString(), dst.toAbsolutePath().normalize().toString())) return;

		Path parent = dst.getParent();
		if (parent != null && Files.notExists(parent)) {
			Files.createDirectories(parent);
		}
		try {
			Files.move(src, dst, StandardCopyOption.ATOMIC_MOVE);
		} catch (AtomicMoveNotSupportedException e) {
			// izbjegni koliziju imena
			if (Files.exists(dst)) {
				String base = dst.getFileName().toString();
				String name = base;
				String ext  = "";
				int dot = base.lastIndexOf('.');
				if (dot > 0) { name = base.substring(0, dot); ext = base.substring(dot); }
				java.nio.file.Path cand;
				int i = 1;
				do {
					cand = dst.getParent().resolve(name + "_" + i + ext);
					i++;
				} while (Files.exists(cand));
				dst = cand;
			}
			Files.move(src, dst, StandardCopyOption.REPLACE_EXISTING);
		}
	}

	public void processQueryDocumentProcessStatusInboxResponse(
			QueryDocumentProcessStatusInboxResponse response,
			Connection conn
	) throws SQLException {

		if (response == null || response.getDocumentHeaders() == null || response.getDocumentHeaders().isEmpty()) {
			logger.info("Nema inbox process-status zapisa za obradu.");
			return;
		}

		for (InboxDocumentHeader h : response.getDocumentHeaders()) {
			try {
				EracunDokument doc = dokumentDao.findByElectronicId(h.getElectronicId(), false, conn);
				if (doc == null) {
					logger.info("📥 (inbox) ProcessStatus stigao za nepoznat ElectronicId=" + h.getElectronicId() + " — preskačem insert (ulaz).");
					continue;
				}

				Integer oldId = doc.getProcesniStatusId();
				Integer newId = h.getDocumentProcessStatusId();
				String  newName = h.getDocumentProcessStatusName();

				if ((newName == null || newName.trim().isEmpty()) && newId != null) {
					try { newName = MerProcessStatus.fromId(newId).getName(); } catch (Throwable ignore) {}
				}

				if (!Objects.equals(oldId, newId)) {
					doc.setProcesniStatusId(newId);
					doc.setProcesniStatusNaziv(newName);

					// datum zadnjeg statusa — u inboxu nema 'Updated', koristi 'Delivered' ako postoji, inače sada
					Timestamp ts = (h.getDelivered() != null) ? parseTimestamp(h.getDelivered()) : new Timestamp(System.currentTimeMillis());
					doc.setDatumZadnjegStatusa(ts);

					dokumentDao.updateStatus(doc);

					// Log promjene procesnog statusa
					EracunDokumentLog log = new EracunDokumentLog();
					log.setIzlazni(false);
					log.setGodina(doc.getGodina());
					log.setPosrednik(doc.getPosrednik());
					log.setVrstaDokumenta(doc.getVrstaDokumenta());
					log.setOnu(doc.getOnu());
					log.setOpp(doc.getOpp());
					log.setBroj(doc.getBroj());
					log.setElectronicId(doc.getElectronicId());

					log.setTransportniStatusId(doc.getTransportniStatusId());
					log.setTransportniStatusNaziv(doc.getTransportniStatusNaziv());
					log.setLokalniStatusId(doc.getLokalniStatusId());
					log.setLokalniStatusNaziv(doc.getLokalniStatusNaziv());

					log.setProcesniStatusId(newId);
					log.setProcesniStatusNaziv(newName);

					log.setDatumStatusa(ts);
					log.setDatumPromjene(new Timestamp(System.currentTimeMillis()));
					log.setPorukaGreske(null);
					log.setOpis("Promjena procesnog statusa (inbox)");
					log.setIzvor(PosrednikType.fromId(cfg.posrednikId()).getCode());
					log.setAkcija("QUERY_PROCESS");

					logDao.insert(log, conn);
				}

				// Snapshot nakon sync-a
				EracunDokumentLog snap = new EracunDokumentLog();
				snap.setIzlazni(false);
				snap.setGodina(doc.getGodina());
				snap.setPosrednik(doc.getPosrednik());
				snap.setVrstaDokumenta(doc.getVrstaDokumenta());
				snap.setOnu(doc.getOnu());
				snap.setOpp(doc.getOpp());
				snap.setBroj(doc.getBroj());
				snap.setElectronicId(doc.getElectronicId());
				snap.setTransportniStatusId(doc.getTransportniStatusId());
				snap.setTransportniStatusNaziv(doc.getTransportniStatusNaziv());
				snap.setLokalniStatusId(doc.getLokalniStatusId());
				snap.setLokalniStatusNaziv(doc.getLokalniStatusNaziv());
				snap.setProcesniStatusId(doc.getProcesniStatusId());
				snap.setProcesniStatusNaziv(doc.getProcesniStatusNaziv());
				snap.setDatumStatusa(doc.getDatumZadnjegStatusa());
				snap.setDatumPromjene(new Timestamp(System.currentTimeMillis()));
				snap.setPorukaGreske(null);
				snap.setOpis("Snapshot (inbox process)");
				snap.setIzvor(PosrednikType.fromId(cfg.posrednikId()).getCode());
				snap.setAkcija("SNAPSHOT_PROCESS");
				logDao.insert(snap, conn);

			} catch (Exception ex) {
				logger.error(new Functions().logging(ex));
			}
		}
	}

	public void processQueryDocumentProcessStatusOutboxResponse(
			QueryDocumentProcessStatusOutboxResponse response,
			Connection conn
	) throws SQLException {

		if (response == null || response.getDocumentHeaders() == null || response.getDocumentHeaders().isEmpty()) {
			logger.info("Nema outbox process-status zapisa za obradu.");
			return;
		}

		final String IZVOR = "MER"; // za uniq (electronic_id, izvor, akcija)

		for (OutboxDocumentHeader h : response.getDocumentHeaders()) {
			try {
				EracunDokument doc = dokumentDao.findByElectronicId(h.getElectronicId(), true, conn);

				Integer newId   = h.getDocumentProcessStatusId();
				String  newName = h.getDocumentProcessStatusName();
				Timestamp ts    = (h.getUpdated() != null) ? parseTimestamp(h.getUpdated())
						: new Timestamp(System.currentTimeMillis());

				if ((newName == null || newName.trim().isEmpty()) && newId != null) {
					try { newName = MerProcessStatus.fromId(newId).getName(); } catch (Throwable ignore) {}
				}
				if (newName == null) newName = "Unknown";

				if (doc == null) {
					// SIROČE → izgradi osnovu iz transportnog headera pa dopiši procesni
					EracunDokument novi = fromHeader(h, conn, true);
					novi.setProcesniStatusId(newId);
					novi.setProcesniStatusNaziv(newName);
					novi.setDatumZadnjegStatusa(ts);

					dokumentDao.insertNoviDokument(conn, novi);
					doc = novi; // dalje logiramo preko zajedničke grane
				} else {
					// POSTOJEĆI dokument → ako je promjena, osvježi
					Integer oldId = doc.getProcesniStatusId();
					if (!java.util.Objects.equals(oldId, newId)) {
						doc.setProcesniStatusId(newId);
						doc.setProcesniStatusNaziv(newName);
						doc.setDatumZadnjegStatusa(ts);
						dokumentDao.updateStatus(doc);
					}
				}

				// Dinamička akcija + kondenzirani (UPSERT) log po statusu
				final int sid = (doc.getProcesniStatusId() != null) ? doc.getProcesniStatusId()
						: (newId != null ? newId : -1);
				final String akcija = "QUERY_PROCESS_" + sid;

				// Minimalni detalji (JSON)
				com.google.gson.JsonObject root = new com.google.gson.JsonObject();
				root.addProperty("shema_verzija", 1);
				root.addProperty("operacija", "PROCESS_STATUS_OUTBOX");
				root.addProperty("status_id", sid);
				com.google.gson.JsonObject s = new com.google.gson.JsonObject();
				s.addProperty("statusName", newName);
				s.addProperty("updatedAt", ts.toString());
				root.add("sadrzaj", s);
				String detaljiJson = new com.google.gson.Gson().toJson(root);

				// Kondenzirani upis
				upsertLogByEid(conn, h.getElectronicId(), IZVOR, akcija, detaljiJson);

			} catch (Exception ex) {
				logger.error(new Functions().logging(ex));
				// nastavi s idućim headerom
			}
		}
	}

	// Jedna idempotentna iteracija provjere PROCESNIH statusa za INBOX
	public void syncProcessStatusInbox() throws Exception {
		final String key = "PROCESS_STATUS_INBOX"; // opKey

		// Circuit pre-guard: preskoči ako je endpoint otvoren (OPEN)
		if (!MerAvailabilityGate.allow(key)) {
			MerSoftFailContext.markSoftFail(
					MerTriageHelper.Category.CONNECTIVITY, null, key,
					"[SYNC] queryDocumentProcessStatusInbox – circuit OPEN, preskačem ciklus.",
					null
			);
			logger.warn("[SYNC] queryDocumentProcessStatusInbox – gate OPEN → skip");
			return;
		}

		Connection conn = null;
		try {
			conn = new __Pool(null).getConnection();

			// Učitaj state za PROCESS_INBOX
			Timestamp now = new Timestamp(System.currentTimeMillis());
			EracunSyncState state = syncStateDao.getState(cfg.posrednikId(), "PROCESS_INBOX", conn);

			Timestamp baseFrom = (state != null && state.getZadnjiSent() != null)
					? state.getZadnjiSent()
					: new Timestamp(now.getTime() - PROCESS_DEFAULT_LOOKBACK_SEC * 1000L);

			long overlapSec = getProcessInboxOverlapSec();
			Timestamp fromTs = new Timestamp(baseFrom.getTime() - overlapSec * 1000L);
			Timestamp toTs   = now;

			String fromIso = toIsoUtcSeconds(fromTs);
			String toIso   = toIsoUtcSeconds(toTs);

			// MER: procesni statusi (INBOX), po update datumu
			QueryDocumentProcessStatusInboxResponse resp;
			try {
				resp = merClient.queryDocumentProcessStatusInbox(
						null, null, null, null,
						fromIso,
						toIso,
						Boolean.TRUE // ByUpdateDate
				);
			} catch (EracunServiceUnavailableException ex) {
				// SOFT-FAIL
				MerSoftFailContext.markSoftFail(
						MerTriageHelper.Category.CONNECTIVITY, null, key,
						"[SYNC] INBOX process-status: SOFT-FAIL (mreža/5xx) – " + ex.getMessage(),
						ex
				);
				MerAvailabilityGate.onFailure(key);
				Logger.getLogger(EracunSyncService.class).warn(new Functions().logging(ex));
				try { if (conn != null) conn.rollback(); } catch (Exception ignore) {}
				return;
			}

			if (resp == null) {
				MerSoftFailContext.markSoftFail(
						MerTriageHelper.Category.CONNECTIVITY, null, key,
						"[SYNC] INBOX process-status: null response (MER nedostupan?)",
						null
				);
				MerAvailabilityGate.onFailure(key);
				logger.warn("[SYNC] INBOX process-status: null response → skip");
				try { if (conn != null) conn.rollback(); } catch (Exception ignore) {}
				return;
			}

			// success → zatvori/relaksiraj circuit
			MerAvailabilityGate.onSuccess(key);

			// Obradi i persistiraj promjene procesnog statusa
			processQueryDocumentProcessStatusInboxResponse(resp, conn);

			// Marker na 'now' (sljedeći put idemo unatrag za overlap)
			if (state == null) {
				syncStateDao.saveState(cfg.posrednikId(), "PROCESS_INBOX", now, null, conn);
			} else {
				state.setZadnjiSent(now);
				syncStateDao.saveState(state, conn);
			}

			logger.info("PROCESS_INBOX sync — from=" + fromIso + ", to=" + toIso
					+ ", overlapSec=" + overlapSec
					+ ", items=" + (resp.getDocumentHeaders() != null ? resp.getDocumentHeaders().size() : 0));

		} catch (Exception ex) {
			Logger.getLogger(EracunSyncService.class).error(new Functions().logging(ex));
			try { if (conn != null) conn.rollback(); } catch (Exception ignore) {}
		} finally {
			try { if (conn != null) conn.close(); } catch (Exception ignore) {}
		}
	}

	private long getProcessInboxOverlapSec() {
		long base = Math.max(safe(cfg.processInboxPeriodSeconds(), 300L), safe(cfg.inboxPeriodSeconds(), 300L));
		return base + SAFETY_MARGIN_SEC;
	}

	private long getProcessOutboxOverlapSec() {
		long base = Math.max(safe(cfg.processOutboxPeriodSeconds(), 300L), safe(cfg.outboxPeriodSeconds(), 300L));
		return base + SAFETY_MARGIN_SEC;
	}

	private static long safe(Long v, long def) {
		return v != null && v > 0 ? v : def;
	}

	private static String trimToSecond(String iso) {
		if (iso == null) return null;
		int dot = iso.indexOf('.');
		return (dot > 0) ? iso.substring(0, dot) : iso;
	}

	private static String toIsoUtcSeconds(java.sql.Timestamp ts) {
		if (ts == null) return null;
		return java.time.OffsetDateTime.ofInstant(ts.toInstant(), java.time.ZoneOffset.UTC)
				.withNano(0)
				.toString(); // npr. 2025-08-21T12:34:56Z
	}

	// Jedna idempotentna iteracija provjere PROCESNIH statusa za OUTBOX
	public void syncProcessStatusOutbox() throws Exception {
		final String key = "PROCESS_STATUS_OUTBOX"; // opKey

		// Circuit pre-guard
		if (!MerAvailabilityGate.allow(key)) {
			MerSoftFailContext.markSoftFail(
					MerTriageHelper.Category.CONNECTIVITY, null, key,
					"[SYNC] queryDocumentProcessStatusOutbox – circuit OPEN, preskačem ciklus.",
					null
			);
			logger.warn("[SYNC] queryDocumentProcessStatusOutbox – gate OPEN → skip");
			return;
		}

		Connection conn = null;
		try {
			conn = new __Pool(null).getConnection();

			Timestamp now = new Timestamp(System.currentTimeMillis());

			// status marker i outbox-dokument marker
			EracunSyncState stStatus = syncStateDao.getState(cfg.posrednikId(), "PROCESS_OUTBOX", conn);
			EracunSyncState stDocs   = syncStateDao.getState(cfg.posrednikId(), "OUTBOX",          conn);

			// baseFrom = zadnji status marker ili lookback
			Timestamp baseFrom = (stStatus != null && stStatus.getZadnjiSent() != null)
					? stStatus.getZadnjiSent()
					: new Timestamp(now.getTime() - PROCESS_DEFAULT_LOOKBACK_SEC * 1000L);

			// poravnaj se unatrag ako je outbox-dokument marker stariji
			if (stDocs != null && stDocs.getZadnjiSent() != null && stDocs.getZadnjiSent().before(baseFrom)) {
				baseFrom = stDocs.getZadnjiSent();
			}

			long overlapSec = getProcessOutboxOverlapSec();
			Timestamp fromTs = new Timestamp(baseFrom.getTime() - overlapSec * 1000L);
			Timestamp toTs   = now;

			String fromIso = toIsoUtcSeconds(fromTs);
			String toIso   = toIsoUtcSeconds(toTs);

			// MER poziv s eksplicitnim prozorom i ByUpdateDate
			QueryDocumentProcessStatusOutboxResponse resp;
			try {
				resp = merClient.queryDocumentProcessStatusOutbox(
						null, null, null, null,
						fromIso, toIso,
						Boolean.TRUE
				);
			} catch (EracunServiceUnavailableException ex) {
				MerSoftFailContext.markSoftFail(
						MerTriageHelper.Category.CONNECTIVITY, null, key,
						"[SYNC] OUTBOX process-status: SOFT-FAIL (mreža/5xx) – " + ex.getMessage(),
						ex
				);
				MerAvailabilityGate.onFailure(key);
				Logger.getLogger(EracunSyncService.class).warn(new Functions().logging(ex));
				try { if (conn != null) conn.rollback(); } catch (Exception ignore) {}
				return;
			}

			if (resp == null) {
				MerSoftFailContext.markSoftFail(
						MerTriageHelper.Category.CONNECTIVITY, null, key,
						"[SYNC] OUTBOX process-status: null response (MER nedostupan?)",
						null
				);
				MerAvailabilityGate.onFailure(key);
				logger.warn("[SYNC] OUTBOX process-status: null response → skip");
				try { if (conn != null) conn.rollback(); } catch (Exception ignore) {}
				return;
			}

			// success
			MerAvailabilityGate.onSuccess(key);

			// obradi odgovor (postojeća metoda)
			processQueryDocumentProcessStatusOutboxResponse(resp, conn);

			// pomakni marker — na 'now'
			if (stStatus == null) {
				syncStateDao.saveState(cfg.posrednikId(), "PROCESS_OUTBOX", now, null, conn);
			} else {
				stStatus.setZadnjiSent(now);
				syncStateDao.saveState(stStatus, conn);
			}

			logger.info("PROCESS_OUTBOX sync — from=" + fromIso + ", to=" + toIso
					+ ", overlapSec=" + overlapSec
					+ ", items=" + ((resp.getDocumentHeaders() != null) ? resp.getDocumentHeaders().size() : 0));

		} catch (Exception e) {
			logger.error(new Functions().logging(e));
			try { if (conn != null) conn.rollback(); } catch (Exception ignore) {}
		} finally {
			if (conn != null) try { conn.close(); } catch (Exception ignore) {}
		}
	}

	private void backfillInboxProcessStatusFor(long electronicId, Connection conn) {
		try {
			QueryDocumentProcessStatusInboxResponse resp = merClient.queryDocumentProcessStatusInbox(
					electronicId,  // ciljani dokument
					null, null, null,    // statusId, invoiceYear, invoiceNumber
					null, null, null     // From, To, ByUpdateDate
			);
			processQueryDocumentProcessStatusInboxResponse(resp, conn);
		} catch (Exception ex) {
			logger.error(new Functions().logging(ex));
		}
	}

	public void backfillOutboxProcessStatusFor(long electronicId, Connection conn) {
		logger.info("backfillOutboxProcessStatusFor EID=" + electronicId);
		try {
			QueryDocumentProcessStatusOutboxResponse resp = merClient.queryDocumentProcessStatusOutbox(
					electronicId, // ciljani dokument
					null, null, null,            // statusId, invoiceYear, invoiceNumber
					null, null, null             // From, To, ByUpdateDate
			);

			if (resp == null || resp.getDocumentHeaders() == null || resp.getDocumentHeaders().isEmpty()) {
				logger.warn("Nema process-status zapisa za EID=" + electronicId + " (još). Preskačem backfill.");
				return;
			}

			OutboxDocumentHeader first = resp.getDocumentHeaders().get(0);
			logger.debug("backfillOutbox: first status name=" + first.getDocumentProcessStatusName()
					+ ", id=" + first.getDocumentProcessStatusId());

			processQueryDocumentProcessStatusOutboxResponse(resp, conn);
		} catch (Exception ex) {
			logger.error(new Functions().logging(ex));
		}
	}

	/// Orkestracija: povuci status s MER-a pa ga obradi/persistiraj.
	public void syncFiscalizationStatus(Connection extConn,
										MerClient merClient,
										long electronicId,
										int messageType) throws SQLException, IOException {
		final String key = "getFiscalizationStatus"; // per-endpoint ključ (granularni circuit)

		// 0) Per-endpoint circuit guard
		if (!MerAvailabilityGate.allow(key)) {
			MerSoftFailContext.markSoftFail(
					MerTriageHelper.Category.CONNECTIVITY,
					null,
					key,
					"FISC status: circuit OPEN – preskačem ovaj ciklus. eid=" + electronicId + ", messageType=" + messageType,
					null
			);
			logger.warn("[SYNC] getFiscalizationStatus – gate OPEN za " + key + " → preskačem ovaj ciklus.");
			return;
		}

		FiscalizationStatusResponse resp = null;

		try {
			resp = merClient.getFiscalizationStatus(electronicId, messageType);
		} catch (IOException ex) {
			logger.error(new Functions().logging(ex));
			throw ex;
		}

		// 1) mreža/5xx ⇒ MerClient vraća null → SOFT-FAIL (bez diranja baze)
		if (resp == null) {
			MerSoftFailContext.markSoftFail(
					MerTriageHelper.Category.CONNECTIVITY,
					null,
					key,
					"FISC status: SOFT-FAIL (MER nedostupan / HTTP 5xx / mreža). eid=" + electronicId + ", messageType=" + messageType,
					null
			);
			MerAvailabilityGate.onFailure(key);
			logger.warn("FISC status: SOFT-FAIL – preskačem obradu i ostavljam za kasniji retry. key=" + key);
			return;
		}

		// 2) valjan odgovor → zatvori/relaksiraj circuit
		MerAvailabilityGate.onSuccess(key);

		// 3) koristi vanjsku konekciju ako je došla
		Connection conn = extConn != null ? extConn : new __Pool(null).getConnection();
		boolean close = (extConn == null);

		try {
			processFiscalizationStatusResponse(conn, electronicId, resp);
		} finally {
			if (close) {
				try { conn.close(); } catch (Exception ignore) {}
			}
		}
	}

	// Obrada i persist – direktno u eracun_dokument i eracun_dokument_log.
	public void processFiscalizationStatusResponse(Connection conn, long electronicId, FiscalizationStatusResponse response) throws SQLException {
		// --- 1) Mapiranje bool -> enum/id ---
		int fiskStatus = MerFiscalizationStatus.UNKNOWN.getId();
		String statusNaziv = "Unknown";
		if (response != null && response.getIsSuccess() != null) {
			if (Boolean.TRUE.equals(response.getIsSuccess())) {
				fiskStatus = MerFiscalizationStatus.SUCCESS.getId(); // očekivano 0
				statusNaziv = "Success";
			} else {
				fiskStatus = MerFiscalizationStatus.FAILED.getId();  // očekivano 1
				statusNaziv = "Failed";
			}
		}

		// --- 2) Vrijeme akcije (ISO-8601) ---
		Timestamp actionTime = null;
		if (response != null && response.getFiscalizationTimestamp() != null) {
			try {
				actionTime = Timestamp.from(OffsetDateTime.parse(response.getFiscalizationTimestamp()).toInstant());
			} catch (Exception ex) {
				logger.error(new Functions().logging(ex));
			}
		}

		// --- 3) Update headera ---
		final String sqlUpdate = "UPDATE eracun_dokument SET fiskaliziran_status = ? WHERE electronic_id = ?";
		try (PreparedStatement ps = conn.prepareStatement(sqlUpdate)) {
			ps.setInt(1, fiskStatus);
			ps.setLong(2, electronicId);
			ps.executeUpdate();
		}

		// --- 4) Log (HR-JSON) ---
		JsonObject root = new JsonObject();
		root.addProperty("shema_verzija", 1);
		root.addProperty("operacija", "FISC_STATUS");
		root.addProperty("status_id", fiskStatus);
		root.addProperty("status_naziv", statusNaziv);
		if (actionTime != null) {
			root.addProperty("vrijeme_akcije", response.getFiscalizationTimestamp());
		}
		root.add("sadrzaj", new JsonObject());

		if (response != null && response.getEncodedXml() != null) {
			JsonObject xml = new JsonObject();
			xml.addProperty("vrsta", "base64");
			xml.addProperty("vrijednost", response.getEncodedXml());
			root.add("xml", xml);
		}

		insertLogJson(conn, electronicId, "MER", (response != null ? "FISC_OK" : "FISC_404"), root);
	}

	// Obrada i persist – direktno u eracun_dokument i eracun_dokument_log.
	public void processMarkPaidResponse(Connection conn,
                                    long electronicId,
                                    MarkPaidResponse response) throws SQLException {
		// 1) Odredi status prema v1.8 response-u
		int paidStatus = MerMarkPaidStatus.UNKNOWN.getId();
		if (response != null && response.getIsSuccess() != null) {
			paidStatus = Boolean.TRUE.equals(response.getIsSuccess())
					? MerMarkPaidStatus.SUCCESS.getId()
					: MerMarkPaidStatus.FAILED.getId();
		}

		// 2) Parsiraj vrijeme akcije (ako je zadano)
		Timestamp actionTime = null;
		if (response != null && response.getFiscalizationTimestamp() != null) {
			try {
				actionTime = Timestamp.from(OffsetDateTime
						.parse(response.getFiscalizationTimestamp())
						.toInstant());
			} catch (Exception ex) {
				logger.error(new Functions().logging(ex));
			}
		}

		// 3) Ažuriraj placen_status u zaglavlju
		final String sqlUpdate = "UPDATE eracun_dokument " +
				"   SET placen_status = ? " +
				" WHERE electronic_id = ?";
		try (PreparedStatement ps = conn.prepareStatement(sqlUpdate)) {
			ps.setInt(1, paidStatus);
			ps.setLong(2, electronicId);
			ps.executeUpdate();
		}

		// 4) Upis u log (HR-JSON) — v1.8 polja
		JsonObject root = new JsonObject();
		root.addProperty("shema_verzija", 1);
		root.addProperty("operacija", "MARK_PAID");
		root.addProperty("status_id", paidStatus);

		if (actionTime != null) { // spremamo originalni ISO-8601 iz response-a			
			root.addProperty("vrijeme_akcije", response.getFiscalizationTimestamp());
		}

		root.add("sadrzaj", new JsonObject());

		// v1.8: encodedXml (base64 string) umjesto xmlBytes
		if (response != null && response.getEncodedXml() != null) {
			JsonObject xml = new JsonObject();
			xml.addProperty("vrsta", "base64");
			xml.addProperty("vrijednost", response.getEncodedXml());
			root.add("xml", xml);
		}

		insertLogJson(conn, electronicId, "MER", (response != null ? "PAID_OK" : "PAID_404"), root);
	}


	public void processRejectResponse(Connection conn,
									  long electronicId,
									  RejectResponse response) throws SQLException {
		// --- 1) Mapiranje bool -> enum/id ---
		int rejectStatus = MerRejectStatus.UNKNOWN.getId();
		String statusNaziv = "Unknown";
		if (response != null && response.getIsSuccess() != null) {
			if (Boolean.TRUE.equals(response.getIsSuccess())) {
				rejectStatus = MerRejectStatus.SUCCESS.getId(); // očekivano 0
				statusNaziv = "Success";
			} else {
				rejectStatus = MerRejectStatus.FAILED.getId();  // očekivano 1
				statusNaziv = "Failed";
			}
		}

		// --- 2) Vrijeme akcije ---
		Timestamp actionTime = null;
		if (response != null && response.getFiscalizationTimestamp() != null) {
			try {
				actionTime = Timestamp.from(OffsetDateTime.parse(response.getFiscalizationTimestamp()).toInstant());
			} catch (Exception ex) {
				logger.error(new Functions().logging(ex));
			}
		}

		// --- 3) Update headera ---
		final String sqlUpdate = "UPDATE eracun_dokument SET odbijen_status = ? WHERE electronic_id = ?";
		try (PreparedStatement ps = conn.prepareStatement(sqlUpdate)) {
			ps.setInt(1, rejectStatus);
			ps.setLong(2, electronicId);
			ps.executeUpdate();
		}

		// --- 4) Log (HR-JSON) ---
		JsonObject root = new JsonObject();
		root.addProperty("shema_verzija", 1);
		root.addProperty("operacija", "REJECT");
		root.addProperty("status_id", rejectStatus);
		root.addProperty("status_naziv", statusNaziv);
		if (actionTime != null) {
			root.addProperty("vrijeme_akcije", response.getFiscalizationTimestamp());
		}
		root.add("sadrzaj", new JsonObject());

		if (response != null && response.getEncodedXml() != null) {
			JsonObject xml = new JsonObject();
			xml.addProperty("vrsta", "base64");
			xml.addProperty("vrijednost", response.getEncodedXml());
			root.add("xml", xml);
		}

		// ostavljam isti “kondenzirani” obrazac kao i ranije
		upsertLogByEid(conn, electronicId, "MER", (response != null ? "REJECT_OK" : "REJECT_404"), root.toString());
	}


	// Upis loga s HR-JSON detaljima (detalji = TEXT/CLOB u bazi)
	private void insertLogJson(Connection conn,
							   long electronicId,
							   String izvor,
							   String akcija,
							   JsonObject detaljiJson) throws SQLException {
		final String sql =
				"INSERT INTO eracun_dokument_log (" +
				"  izlazni, godina, posrednik, vrsta_dokumenta, onu, opp, broj, " +
				"  electronic_id, " +
				"  lokalni_status_id, lokalni_status_naziv, " +
				"  transportni_status_id, transportni_status_naziv, " +
				"  procesni_status_id, procesni_status_naziv, " +
				"  datum_statusa, datum_promjene, " +
				"  poruka_greske, opis, putanja_xml, izvor, akcija, oib_operatera, detalji" +
				") " +
				"SELECT " +
				"  d.izlazni, d.godina, d.posrednik, d.vrsta_dokumenta, d.onu, d.opp, d.broj, " +
				"  d.electronic_id, " +
				"  d.lokalni_status_id, d.lokalni_status_naziv, " +
				"  d.transportni_status_id, d.transportni_status_naziv, " +
				"  d.procesni_status_id, d.procesni_status_naziv, " +
				"  d.datum_zadnjeg_statusa, ?, " +            // datum_promjene (NOW)
				"  NULL, NULL, d.putanja_xml, ?, ?, NULL, ? " + // izvor, akcija, detalji
				"FROM eracun_dokument d " +
				"WHERE d.electronic_id = ?";

		try (PreparedStatement ps = conn.prepareStatement(sql)) {
			int i = 1;
			ps.setTimestamp(i++, new Timestamp(System.currentTimeMillis())); // datum_promjene
			ps.setString(i++, izvor);
			ps.setString(i++, akcija);
			ps.setString(i++, (detaljiJson != null ? detaljiJson.toString() : null)); // detalji JSON
			ps.setLong(i++, electronicId);
			int rows = ps.executeUpdate();
			if (rows == 0) {
				logger.warn("insertLogJson: nema eracun_dokument za EID=" + electronicId +
						" — log nije upisan.");
			}
		}
	}

	private void upsertLogByEid(Connection conn,
								long electronicId,
								String izvor,
								String akcija,        // npr. "QUERY_PROCESS_4"
								String detaljiJson) throws SQLException {

		final String sql =
				"INSERT INTO eracun_dokument_log (" +
				"  izlazni, godina, posrednik, vrsta_dokumenta, onu, opp, broj, " +
				"  electronic_id, " +
				"  lokalni_status_id, lokalni_status_naziv, " +
				"  transportni_status_id, transportni_status_naziv, " +
				"  procesni_status_id, procesni_status_naziv, " +
				"  datum_statusa, datum_promjene, " +
				"  poruka_greske, opis, putanja_xml, izvor, akcija, oib_operatera, detalji" +
				") " +
				"SELECT " +
				"  d.izlazni, d.godina, d.posrednik, d.vrsta_dokumenta, d.onu, d.opp, d.broj, " +
				"  d.electronic_id, " +
				"  d.lokalni_status_id, d.lokalni_status_naziv, " +
				"  d.transportni_status_id, d.transportni_status_naziv, " +
				"  d.procesni_status_id, d.procesni_status_naziv, " +
				"  d.datum_zadnjeg_statusa, ?, " +                     // datum_promjene = NOW
				"  NULL, NULL, d.putanja_xml, ?, ?, NULL, ? " +        // izvor, akcija, detalji
				"FROM eracun_dokument d " +
				"WHERE d.electronic_id = ? " +
				"ON DUPLICATE KEY UPDATE " +
				"  datum_promjene        = VALUES(datum_promjene), " +
				"  lokalni_status_id     = VALUES(lokalni_status_id), " +
				"  lokalni_status_naziv  = VALUES(lokalni_status_naziv), " +
				"  transportni_status_id = VALUES(transportni_status_id), " +
				"  transportni_status_naziv = VALUES(transportni_status_naziv), " +
				"  procesni_status_id    = VALUES(procesni_status_id), " +
				"  procesni_status_naziv = VALUES(procesni_status_naziv), " +
				"  datum_statusa         = VALUES(datum_statusa), " +
				"  detalji               = VALUES(detalji)";

		try (PreparedStatement ps = conn.prepareStatement(sql)) {
			int i = 1;
			ps.setTimestamp(i++, new java.sql.Timestamp(System.currentTimeMillis())); // datum_promjene
			ps.setString(i++, izvor);
			ps.setString(i++, akcija);
			ps.setString(i++, detaljiJson);
			ps.setLong(i++, electronicId);
			ps.executeUpdate();
		}
	}
}