package com.zioinfo.kintex.settlement;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.zioinfo.kintex.auth.EventRole;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.PageResponse;
import com.zioinfo.kintex.common.audit.AuditLogService;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.settlement.dto.SettlementDtos.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

/**
 * M9 정산·결제 서비스 — 청구 집계·현황 대시보드·상태 전이·수납 기록.
 *
 * <p><b>결제 = PG 위임 고지 패턴</b>: 실제 PG 연동은 없다. 상태 전이(pending→invoiced→paid/overdue)와
 * 수동 수납 기록만 수행한다(공개 P 화면 결제 고지와 동일 정책). <b>금액은 서버 계산이 권위</b> —
 * 임대료 = 판매면적(ST_Area) × 요율(rates-v1.0), 유틸리티 = utility_order 합, 부가세 10%.
 *
 * <p><b>권한</b>: 조회는 행사 접근 권한(멤버·홀매니저). 청구 생성·발행·수납 기록은 매니저 이상
 * (주최자 또는 홀매니저)만 — 컨트롤러 {@code @Audited} + 서비스 재검증.
 */
@Service
public class SettlementService {

    private static final int MAX_LIMIT = 200;
    private static final double VAT_RATE = 0.10;
    private static final String PAYMENT_NOTE =
            "결제는 외부 PG(전자결제)로 위임됩니다. 본 화면은 청구·수납 현황 관리 전용이며 카드정보는 저장하지 않습니다.";
    /** F085 환불 PG 미집행 고지(G2 재무 게이트). */
    private static final String REFUND_PG_NOTE =
            "환불액은 규정 기준 서버 산정·기록 값이며, 실제 환불(전자결제/PG 역거래)은 킨텍스 재무 확정 후 집행됩니다.";
    /** F083 세금계산서 국세청 미전송 고지(G2 재무 게이트). */
    private static final String TAX_ISSUE_NOTE =
            "본 세금계산서는 시스템 내 문서 데이터이며 국세청 전자세금계산서 전송은 킨텍스 재무 프로세스로 별도 처리됩니다.";

    // ── 공급자(킨텍스) 상수 — 재무 확정 전 플레이스홀더(G2). 민감정보 아님(공개 정보/플레이스홀더). ──
    private static final String SUPPLIER_NAME = "킨텍스(KINTEX, 한국국제전시장)";
    private static final String SUPPLIER_BIZ_NO = "000-00-00000"; // 재무 확정 전 플레이스홀더
    private static final String SUPPLIER_CEO = "대표이사";          // 재무 확정 전 플레이스홀더
    private static final String SUPPLIER_ADDR = "경기도 고양시 일산서구 킨텍스로 217-60";

    private static final ObjectMapper JSON = new ObjectMapper();
    private static final DateTimeFormatter ISO_DATE = DateTimeFormatter.ISO_LOCAL_DATE;

    private final SettlementMapper mapper;
    private final AuditLogService audit;
    private final RefundPolicy refundPolicy;

    public SettlementService(SettlementMapper mapper, AuditLogService audit, RefundPolicy refundPolicy) {
        this.mapper = mapper;
        this.audit = audit;
        this.refundPolicy = refundPolicy;
    }

    // ── 대시보드 ──────────────────────────────────────────────────────────────
    public SettlementDashboard getDashboard(String eventId) {
        Map<String, Object> t = mapper.dashboardTotals(eventId);
        long billed = lng(t == null ? null : t.get("billed"));
        long collected = lng(t == null ? null : t.get("collected"));
        long invoiceCount = lng(t == null ? null : t.get("invoiceCount"));
        long overdueCount = lng(t == null ? null : t.get("overdueCount"));
        long outstanding = Math.max(0, billed - collected);
        double rate = billed > 0 ? round1((collected * 100.0) / billed) : 0.0;

        List<StatusBucket> buckets = new ArrayList<>();
        List<Map<String, Object>> rows = mapper.statusDistribution(eventId);
        if (rows != null) {
            for (Map<String, Object> r : rows) {
                buckets.add(new StatusBucket(
                        str(r.get("status")), lng(r.get("count")),
                        lng(r.get("totalAmount")), lng(r.get("paidAmount"))));
            }
        }
        return new SettlementDashboard(eventId, billed, collected, outstanding,
                invoiceCount, overdueCount, rate, buckets, PAYMENT_NOTE);
    }

    // ── 목록 ──────────────────────────────────────────────────────────────────
    public PageResponse<InvoiceSummary> listInvoices(String eventId, String status, int page, int size) {
        int limit = Math.min(Math.max(size, 1), MAX_LIMIT);
        int offset = Math.max(page, 0) * limit;
        String st = nullIfBlank(status);
        long total = mapper.countInvoices(eventId, st);
        List<Map<String, Object>> rows = mapper.listInvoices(eventId, st, limit, offset);
        List<InvoiceSummary> items = new ArrayList<>();
        if (rows != null) {
            for (Map<String, Object> r : rows) {
                items.add(toSummary(r));
            }
        }
        return PageResponse.of(items, Math.max(page, 0), limit, total);
    }

    // ── 상세 ──────────────────────────────────────────────────────────────────
    public InvoiceDetail getDetail(String id, KintexPrincipal principal) {
        Map<String, Object> r = mapper.findInvoice(id);
        if (r == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        long subtotal = lng(r.get("subtotal"));
        long vat = lng(r.get("vat"));
        long total = lng(r.get("total"));
        long paid = lng(r.get("paidAmount"));
        List<LineItem> lines = parseLines(str(r.get("lineItems")));
        List<PaymentRecord> payments = new ArrayList<>();
        List<Map<String, Object>> pr = mapper.listPayments(id);
        if (pr != null) {
            for (Map<String, Object> p : pr) {
                payments.add(new PaymentRecord(
                        str(p.get("id")), lng(p.get("amount")), str(p.get("method")),
                        str(p.get("memo")), str(p.get("recordedAt"))));
            }
        }
        boolean canManage = isManager(principal, str(r.get("eventId")));
        return new InvoiceDetail(
                str(r.get("id")), str(r.get("eventId")), str(r.get("invoiceNo")),
                str(r.get("exhibitorName")), subtotal, vat, total, paid,
                Math.max(0, total - paid), str(r.get("status")),
                str(r.get("dueDate")), str(r.get("issuedAt")), str(r.get("paidAt")),
                str(r.get("note")), categoryOf(r.get("category")), lines, payments, canManage);
    }

    // ── 발행(pending → invoiced) ──────────────────────────────────────────────
    @Transactional
    public InvoiceDetail issue(String id, KintexPrincipal principal) {
        Map<String, Object> r = mapper.findInvoice(id);
        if (r == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        requireManager(principal, str(r.get("eventId")));
        if (!"pending".equals(str(r.get("status")))) {
            throw new ApiException(ErrorCode.CONFLICT); // 이미 발행/수납/완납된 건
        }
        mapper.issueInvoice(id);
        return getDetail(id, principal);
    }

    // ── 수납 기록(수동) ────────────────────────────────────────────────────────
    @Transactional
    public InvoiceDetail recordPayment(String id, KintexPrincipal principal, RecordPaymentRequest req) {
        Map<String, Object> r = mapper.findInvoice(id);
        if (r == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        requireManager(principal, str(r.get("eventId")));
        if (req == null || req.amount() == null || req.amount() <= 0) {
            throw new ApiException(ErrorCode.VALIDATION);
        }
        long total = lng(r.get("total"));
        long paid = lng(r.get("paidAmount"));
        if (paid + req.amount() > total) {
            throw new ApiException(ErrorCode.VALIDATION); // 총 청구액 초과 수납 방지
        }
        Map<String, Object> p = new HashMap<>();
        p.put("id", "pay-" + UUID.randomUUID().toString().substring(0, 12));
        p.put("invoiceId", id);
        p.put("amount", req.amount());
        p.put("method", mapMethod(req.method()));
        p.put("memo", nullIfBlank(req.memo()));
        p.put("recordedBy", principal.userId());
        mapper.insertPayment(p);
        mapper.applyPayment(id); // paid_amount 재계산 + 상태 파생(완납 시 paid)
        return getDetail(id, principal);
    }

    // ── 청구서 생성(집계) ─────────────────────────────────────────────────────
    /**
     * 행사 참가업체별로 임대료(면적×요율)+유틸리티 합을 집계해 청구서를 생성(pending).
     * (event_id, exhibitor_name) 멱등 — 이미 존재하는 참가업체는 건너뛴다.
     */
    @Transactional
    public GenerateResult generate(String eventId, KintexPrincipal principal) {
        if (nullIfBlank(eventId) == null) {
            throw new ApiException(ErrorCode.VALIDATION);
        }
        requireManager(principal, eventId);

        Double rateObj = mapper.findRentalRatePerM2();
        double ratePerM2 = rateObj == null ? 0.0 : rateObj;

        Map<String, Long> utilByKey = new HashMap<>();
        List<Map<String, Object>> utilRows = mapper.aggregateUtilityByExhibitor(eventId);
        if (utilRows != null) {
            for (Map<String, Object> u : utilRows) {
                utilByKey.put(str(u.get("companyKey")), Math.round(dbl(u.get("utility"))));
            }
        }

        List<Map<String, Object>> rentalRows = mapper.aggregateRentalByExhibitor(eventId);
        int created = 0;
        int skipped = 0;
        int seq = mapper.countInvoicesForSeq(eventId);
        List<InvoiceSummary> out = new ArrayList<>();
        if (rentalRows != null) {
            for (Map<String, Object> row : rentalRows) {
                String key = str(row.get("companyKey"));
                String name = str(row.get("exhibitorName"));
                double area = dbl(row.get("areaM2"));
                long boothCount = lng(row.get("boothCount"));
                long rental = Math.round(area * ratePerM2);
                long utility = utilByKey.getOrDefault(key, 0L);
                long subtotal = rental + utility;
                long vat = Math.round(subtotal * VAT_RATE);
                long total = subtotal + vat;

                List<Map<String, Object>> lines = new ArrayList<>();
                lines.add(lineItem("RENTAL",
                        "부스 임대료 (" + Math.round(area) + "㎡ · " + boothCount + "부스)",
                        Math.round(area), Math.round(ratePerM2), rental));
                if (utility > 0) {
                    lines.add(lineItem("UTILITY", "유틸리티(전기·네트워크·급배수)", 1, utility, utility));
                }

                seq++;
                String invoiceNo = eventInvoiceNo(eventId, seq);
                Map<String, Object> p = new HashMap<>();
                p.put("id", "inv-" + UUID.randomUUID().toString().substring(0, 12));
                p.put("eventId", eventId);
                p.put("exhibitorName", name);
                p.put("invoiceNo", invoiceNo);
                p.put("subtotal", subtotal);
                p.put("vat", vat);
                p.put("total", total);
                p.put("lineItems", toJson(lines));
                p.put("createdBy", principal.userId());
                int n = mapper.insertInvoice(p);
                if (n > 0) {
                    created++;
                    out.add(new InvoiceSummary(str(p.get("id")), eventId, invoiceNo, name,
                            subtotal, vat, total, 0, total, "pending", null, null, "rental"));
                } else {
                    skipped++;
                    seq--; // 충돌로 미생성 — 번호 되돌림
                }
            }
        }
        return new GenerateResult(eventId, created, skipped, out);
    }

    // ── F084 옥션 수수료 청구 연동 ────────────────────────────────────────────
    /**
     * 낙찰 확정 시 옥션 수수료를 계산해 정산 청구서(category=auction_fee)를 생성한다.
     *
     * <p><b>멱등</b>: 같은 낙찰(awardId=source_ref)로는 1건만 — 재호출/재시도 시 중복 생성하지 않는다
     * (source_ref 부분 유니크 인덱스 + 사전 카운트). 수수료 0(요율/낙찰가 0)이면 청구를 생성하지 않는다.
     * 감사({@code AUCTION_FEE_INVOICE})는 프로그램적으로 기록한다(민감정보 미포함).
     *
     * @param winningTotalExVat 낙찰 응찰의 부가세 별도 경쟁 금액(bid.total).
     * @return 생성 결과(생성 여부·수수료 금액). 업무 흐름을 막지 않도록 예외를 던지지 않는다.
     */
    @Transactional
    public AuctionFeeResult createAuctionFeeInvoice(String eventId, String awardId, String auctionId,
                                                    String auctionTitle, String companyName,
                                                    long winningTotalExVat, KintexPrincipal actor) {
        AuctionFee.Fee fee = AuctionFee.compute(winningTotalExVat, mapper.findAuctionFeeRate());
        if (fee.fee() <= 0) {
            return new AuctionFeeResult(false, null, null, 0, 0, 0, fee.ratePercent(), "수수료 0(요율/낙찰가)");
        }
        if (nullIfBlank(awardId) == null || mapper.countInvoiceBySourceRef(awardId) > 0) {
            return new AuctionFeeResult(false, null, null, fee.fee(), fee.vat(), fee.total(),
                    fee.ratePercent(), "이미 생성된 수수료 청구(멱등)");
        }

        String coName = companyName == null ? "낙찰업체" : companyName;
        String exhibitorName = clip(coName + " · 옥션수수료(" + auctionId + ")", 200);
        String invoiceNo = clip("FEE-" + auctionId, 40);
        String rateLabel = fmtRate(fee.ratePercent());

        List<Map<String, Object>> lines = new ArrayList<>();
        lines.add(lineItem("AUCTION_FEE",
                "옥션 낙찰 수수료 (낙찰가 " + fee.base() + "원 · 요율 " + rateLabel + "%)",
                1, fee.fee(), fee.fee()));

        Map<String, Object> p = new HashMap<>();
        p.put("id", "inv-" + UUID.randomUUID().toString().substring(0, 12));
        p.put("eventId", eventId);
        p.put("exhibitorName", exhibitorName);
        p.put("invoiceNo", invoiceNo);
        p.put("sourceRef", awardId);
        p.put("subtotal", fee.fee());
        p.put("vat", fee.vat());
        p.put("total", fee.total());
        p.put("lineItems", toJson(lines));
        p.put("note", clip("옥션 낙찰 수수료 자동 청구 — " + (auctionTitle == null ? auctionId : auctionTitle), 500));
        p.put("createdBy", actor == null ? "system" : actor.userId());
        int n = mapper.insertAuctionFeeInvoice(p);
        if (n == 0) {
            // 경합/멱등 충돌 — 이미 생성됨으로 간주.
            return new AuctionFeeResult(false, null, invoiceNo, fee.fee(), fee.vat(), fee.total(),
                    fee.ratePercent(), "이미 생성된 수수료 청구(멱등)");
        }
        audit.record(
                actor == null ? "system" : actor.userId(),
                actor == null ? null : actor.displayName(),
                "AUCTION_FEE_INVOICE", "invoice", str(p.get("id")), eventId,
                "옥션 수수료 청구 생성 " + invoiceNo + " · 요율 " + rateLabel + "% · 총액 " + fee.total() + "원",
                "SUCCESS");
        return new AuctionFeeResult(true, str(p.get("id")), invoiceNo,
                fee.fee(), fee.vat(), fee.total(), fee.ratePercent(), null);
    }

    // ── F085 환불·취소 규정 ──────────────────────────────────────────────────
    /** 환불 산정 미리보기(취소 확정 전) — 매니저 이상. cancelDate 미지정 시 오늘 기준. */
    public RefundQuote refundQuote(String id, KintexPrincipal principal, String cancelDate) {
        Map<String, Object> r = mapper.findInvoice(id);
        if (r == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        requireManager(principal, str(r.get("eventId")));
        return computeRefundQuote(r, cancelDate);
    }

    /**
     * 청구 취소 — 환불액 서버 산정 + 상태 cancelled + 환불 레코드 기록. 실 PG 환불 없음(G2 게이트).
     * 이미 취소된 청구는 409. 환불 근거(구간·요율·기준액)를 함께 반환한다.
     */
    @Transactional
    public CancelResult cancel(String id, KintexPrincipal principal, CancelInvoiceRequest req) {
        Map<String, Object> r = mapper.findInvoice(id);
        if (r == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        String eventId = str(r.get("eventId"));
        requireManager(principal, eventId);
        if ("cancelled".equals(str(r.get("status")))) {
            throw new ApiException(ErrorCode.CONFLICT); // 이미 취소된 청구
        }
        String cancelDate = req == null ? null : req.cancelDate();
        String reason = clip(req == null ? null : nullIfBlank(req.reason()), 500);
        RefundQuote q = computeRefundQuote(r, cancelDate);

        int n = mapper.cancelInvoice(id, reason);
        if (n == 0) {
            throw new ApiException(ErrorCode.CONFLICT); // 경합으로 이미 취소됨
        }

        Map<String, Object> ref = new HashMap<>();
        ref.put("id", "rfnd-" + UUID.randomUUID().toString().substring(0, 12));
        ref.put("invoiceId", id);
        ref.put("eventId", eventId);
        ref.put("baseAmount", q.baseAmount());
        ref.put("refundRatePercent", q.refundRatePercent());
        ref.put("refundAmount", q.refundAmount());
        ref.put("forfeitAmount", q.forfeitAmount());
        ref.put("daysBeforeEvent", q.daysBeforeEvent());
        ref.put("bracketLabel", clip(q.bracketLabel(), 80));
        ref.put("reason", reason);
        ref.put("rulesetVersion", refundPolicy.version());
        ref.put("createdBy", principal == null ? "system" : principal.userId());
        mapper.insertRefund(ref);

        audit.record(
                principal == null ? "system" : principal.userId(),
                principal == null ? null : principal.displayName(),
                "SETTLEMENT_REFUND", "invoice", id, eventId,
                "청구 취소 환불 산정 " + str(r.get("invoiceNo")) + " · 요율 " + fmtRate(q.refundRatePercent())
                        + "% · 환불 " + q.refundAmount() + "원 · 공제 " + q.forfeitAmount() + "원",
                "SUCCESS");

        return new CancelResult(getDetail(id, principal), q);
    }

    private RefundQuote computeRefundQuote(Map<String, Object> inv, String cancelDate) {
        String invoiceId = str(inv.get("id"));
        String invoiceNo = str(inv.get("invoiceNo"));
        String status = str(inv.get("status"));
        String eventId = str(inv.get("eventId"));
        long total = lng(inv.get("total"));
        long base = lng(inv.get("paidAmount")); // 되돌려줄 수 있는 실 납입액 기준

        String startStr = mapper.findEventStartDate(eventId);
        LocalDate cancel = parseDateOrToday(cancelDate);

        Integer daysBefore = null;
        boolean resolved;
        double rate;
        String label;
        if (startStr != null) {
            LocalDate start = LocalDate.parse(startStr);
            int d = (int) ChronoUnit.DAYS.between(cancel, start);
            daysBefore = d;
            if (d < 0) {
                rate = refundPolicy.afterEventRatePercent();
                label = "행사 종료 후";
                resolved = true;
            } else {
                RefundCalc.Bracket b = RefundCalc.bracketFor(d, refundPolicy.brackets());
                if (b != null) {
                    rate = b.refundRatePercent();
                    label = b.label();
                    resolved = true;
                } else {
                    rate = refundPolicy.unknownDateRatePercent();
                    label = "구간 미매칭 — 재무 검토";
                    resolved = false;
                }
            }
        } else {
            rate = refundPolicy.unknownDateRatePercent();
            label = "개막일 미확정 — 재무 검토";
            resolved = false;
        }
        long refund = RefundCalc.refundAmount(base, rate);
        long forfeit = RefundCalc.forfeitAmount(base, refund);
        return new RefundQuote(invoiceId, invoiceNo, status, total, base, rate, refund, forfeit,
                daysBefore, label, startStr, resolved, refundPolicy.version(), REFUND_PG_NOTE);
    }

    // ── F083 세금계산서 ──────────────────────────────────────────────────────
    /** 세금계산서 조회(있으면 반환, 없으면 null 계약 — 컨트롤러에서 처리). */
    public TaxInvoiceDto getTaxInvoice(String id, KintexPrincipal principal) {
        Map<String, Object> inv = mapper.findInvoice(id);
        if (inv == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        Map<String, Object> t = mapper.findTaxInvoiceByInvoice(id);
        return t == null ? null : toTaxInvoice(t);
    }

    /**
     * 세금계산서 발행(문서 데이터 생성) — 매니저 이상. 청구서당 1건(멱등). 발행 전(pending)·취소 청구는 409.
     * 국세청 전송 없음(G2 게이트). 공급자=킨텍스 상수, 공급받는자=참가업체 표시명, 금액=청구 스냅샷.
     */
    @Transactional
    public TaxInvoiceDto issueTaxInvoice(String id, KintexPrincipal principal) {
        Map<String, Object> inv = mapper.findInvoice(id);
        if (inv == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        String eventId = str(inv.get("eventId"));
        requireManager(principal, eventId);
        String status = str(inv.get("status"));
        if ("pending".equals(status) || "cancelled".equals(status)) {
            throw new ApiException(ErrorCode.CONFLICT); // 발행 전 초안 또는 취소 청구는 세금계산서 발행 불가
        }
        Map<String, Object> existing = mapper.findTaxInvoiceByInvoice(id);
        if (existing != null) {
            return toTaxInvoice(existing); // 멱등 — 이미 발행됨
        }

        long supply = lng(inv.get("subtotal"));
        long vat = lng(inv.get("vat"));
        long total = lng(inv.get("total"));
        String cat = categoryOf(inv.get("category"));
        String itemSummary = "auction_fee".equals(cat)
                ? "옥션 낙찰 수수료"
                : "부스 임대료 및 부대비용(유틸리티 등)";
        int seq = mapper.countTaxInvoiceForSeq(eventId) + 1;
        String taxNo = clip("TAX-" + eventSuffix(eventId) + "-" + String.format("%04d", seq), 40);

        Map<String, Object> p = new HashMap<>();
        String taxId = "tax-" + UUID.randomUUID().toString().substring(0, 12);
        p.put("id", taxId);
        p.put("invoiceId", id);
        p.put("eventId", eventId);
        p.put("taxInvoiceNo", taxNo);
        p.put("supplierName", SUPPLIER_NAME);
        p.put("supplierBizNo", SUPPLIER_BIZ_NO);
        p.put("supplierCeo", SUPPLIER_CEO);
        p.put("supplierAddr", SUPPLIER_ADDR);
        p.put("buyerName", clip(str(inv.get("exhibitorName")), 200));
        p.put("supplyAmount", supply);
        p.put("vat", vat);
        p.put("total", total);
        p.put("itemSummary", itemSummary);
        p.put("issuedBy", principal == null ? "system" : principal.userId());
        mapper.insertTaxInvoice(p);

        Map<String, Object> t = mapper.findTaxInvoiceByInvoice(id);
        return t == null ? null : toTaxInvoice(t);
    }

    private TaxInvoiceDto toTaxInvoice(Map<String, Object> t) {
        return new TaxInvoiceDto(
                str(t.get("id")), str(t.get("invoiceId")), str(t.get("eventId")),
                str(t.get("taxInvoiceNo")), str(t.get("supplierName")), str(t.get("supplierBizNo")),
                str(t.get("supplierCeo")), str(t.get("supplierAddr")), str(t.get("buyerName")),
                lng(t.get("supplyAmount")), lng(t.get("vat")), lng(t.get("total")),
                str(t.get("itemSummary")), str(t.get("status")), str(t.get("issuedAt")), TAX_ISSUE_NOTE);
    }

    // ── 정산 리포트 ──────────────────────────────────────────────────────────
    /** 행사 정산 리포트 — 카테고리/상태별 합계·수납율·미수 + 상세 행(CSV). 취소분은 합계에서 제외. */
    public SettlementReport report(String eventId) {
        Map<String, Object> tot = mapper.reportTotals(eventId);
        long billed = lng(tot == null ? null : tot.get("billed"));
        long collected = lng(tot == null ? null : tot.get("collected"));
        long outstanding = lng(tot == null ? null : tot.get("outstanding"));
        long count = lng(tot == null ? null : tot.get("invoiceCount"));
        double rate = billed > 0 ? round1((collected * 100.0) / billed) : 0.0;

        List<ReportGroup> byCategory = toGroups(mapper.reportByCategory(eventId));
        List<ReportGroup> byStatus = toGroups(mapper.reportByStatus(eventId));

        List<ReportRow> rows = new ArrayList<>();
        List<Map<String, Object>> rr = mapper.reportRows(eventId);
        if (rr != null) {
            for (Map<String, Object> r : rr) {
                rows.add(new ReportRow(
                        str(r.get("invoiceNo")), str(r.get("exhibitorName")), categoryOf(r.get("category")),
                        str(r.get("status")), lng(r.get("subtotal")), lng(r.get("vat")), lng(r.get("total")),
                        lng(r.get("paidAmount")), lng(r.get("outstanding")), str(r.get("dueDate"))));
            }
        }
        String name = mapper.findEventName(eventId);
        String generatedAt = OffsetDateTime.now(ZoneOffset.UTC)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"));
        return new SettlementReport(eventId, name, generatedAt, billed, collected, outstanding,
                rate, count, byCategory, byStatus, rows);
    }

    private List<ReportGroup> toGroups(List<Map<String, Object>> rows) {
        List<ReportGroup> out = new ArrayList<>();
        if (rows == null) {
            return out;
        }
        for (Map<String, Object> r : rows) {
            long billed = lng(r.get("billed"));
            long collected = lng(r.get("collected"));
            double rate = billed > 0 ? round1((collected * 100.0) / billed) : 0.0;
            out.add(new ReportGroup(str(r.get("key")), lng(r.get("invoiceCount")),
                    billed, collected, lng(r.get("outstanding")), rate));
        }
        return out;
    }

    private static LocalDate parseDateOrToday(String s) {
        if (s == null || s.isBlank()) {
            return LocalDate.now(ZoneOffset.UTC);
        }
        try {
            return LocalDate.parse(s.trim(), ISO_DATE);
        } catch (Exception e) {
            return LocalDate.now(ZoneOffset.UTC);
        }
    }

    private static String eventSuffix(String eventId) {
        String suffix = eventId == null ? "EVT" : eventId.replaceAll("[^A-Za-z0-9]", "").toUpperCase();
        if (suffix.length() > 8) {
            suffix = suffix.substring(suffix.length() - 8);
        }
        return suffix;
    }

    private static String clip(String s, int max) {
        if (s == null) return null;
        return s.length() <= max ? s : s.substring(0, max);
    }

    private static String fmtRate(double v) {
        return v == Math.floor(v) ? String.valueOf((long) v) : String.valueOf(Math.round(v * 100.0) / 100.0);
    }

    // ── 헬퍼 ──────────────────────────────────────────────────────────────────
    private InvoiceSummary toSummary(Map<String, Object> r) {
        long total = lng(r.get("total"));
        long paid = lng(r.get("paidAmount"));
        return new InvoiceSummary(
                str(r.get("id")), str(r.get("eventId")), str(r.get("invoiceNo")),
                str(r.get("exhibitorName")), lng(r.get("subtotal")), lng(r.get("vat")),
                total, paid, Math.max(0, total - paid), str(r.get("status")),
                str(r.get("dueDate")), str(r.get("issuedAt")), categoryOf(r.get("category")));
    }

    private static String categoryOf(Object o) {
        String c = str(o);
        return c == null || c.isBlank() ? "rental" : c;
    }

    /** 매니저 이상(주최자 또는 킨텍스 홀매니저)만 청구·수납 조작 가능. */
    private boolean isManager(KintexPrincipal principal, String eventId) {
        if (principal == null) {
            return false;
        }
        if (principal.hallManager()) {
            return true;
        }
        EventRole role = principal.roleFor(eventId);
        return role == EventRole.ORGANIZER || role == EventRole.HALL_MANAGER;
    }

    private void requireManager(KintexPrincipal principal, String eventId) {
        if (!isManager(principal, eventId)) {
            throw new ApiException(ErrorCode.FORBIDDEN);
        }
    }

    private static Map<String, Object> lineItem(String category, String description,
                                                long quantity, long unitPrice, long amount) {
        Map<String, Object> m = new HashMap<>();
        m.put("category", category);
        m.put("description", description);
        m.put("quantity", quantity);
        m.put("unitPrice", unitPrice);
        m.put("amount", amount);
        return m;
    }

    private List<LineItem> parseLines(String json) {
        List<LineItem> out = new ArrayList<>();
        if (json == null || json.isBlank()) {
            return out;
        }
        try {
            List<Map<String, Object>> raw = JSON.readValue(json, new com.fasterxml.jackson.core.type.TypeReference<>() {
            });
            for (Map<String, Object> m : raw) {
                out.add(new LineItem(
                        str(m.get("category")), str(m.get("description")),
                        lng(m.get("quantity")), lng(m.get("unitPrice")), lng(m.get("amount"))));
            }
        } catch (Exception e) {
            // 스냅샷 파싱 실패 시 라인은 생략(금액 합계는 컬럼값이 권위) — 스택트레이스 미노출.
            return out;
        }
        return out;
    }

    private static String eventInvoiceNo(String eventId, int seq) {
        String suffix = eventId == null ? "EVT" : eventId.replaceAll("[^A-Za-z0-9]", "").toUpperCase();
        if (suffix.length() > 8) {
            suffix = suffix.substring(suffix.length() - 8);
        }
        return suffix + "-" + String.format("%04d", seq);
    }

    private static String mapMethod(String m) {
        if (m == null) {
            return "manual";
        }
        return switch (m.toLowerCase()) {
            case "transfer" -> "transfer";
            case "card" -> "card";
            default -> "manual";
        };
    }

    private static String toJson(Object o) {
        try {
            return JSON.writeValueAsString(o);
        } catch (Exception e) {
            return "[]";
        }
    }

    private static String nullIfBlank(String s) {
        return s == null || s.isBlank() ? null : s;
    }

    private static String str(Object o) {
        return o == null ? null : String.valueOf(o);
    }

    private static long lng(Object o) {
        if (o == null) return 0L;
        if (o instanceof Number n) return n.longValue();
        try {
            return Long.parseLong(String.valueOf(o));
        } catch (NumberFormatException e) {
            return 0L;
        }
    }

    private static double dbl(Object o) {
        if (o == null) return 0.0;
        if (o instanceof Number n) return n.doubleValue();
        try {
            return Double.parseDouble(String.valueOf(o));
        } catch (NumberFormatException e) {
            return 0.0;
        }
    }

    private static double round1(double v) {
        return Math.round(v * 10.0) / 10.0;
    }
}
