package com.zioinfo.kintex.auction;

import com.zioinfo.kintex.auction.dto.AuctionDtos.*;
import com.zioinfo.kintex.auth.EventRole;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.UUID;

/**
 * M15 옥션 서비스 — 목록·상세·응찰·낙찰·발주자 비교·업체 대시보드.
 *
 * <p><b>봉인 입찰 보안(서버 강제)</b>:
 * <ul>
 *   <li>마감 전 상세/순위 응답은 ①내 입찰가 ②현재 최저가(금액만, 업체 비식별) ③참여 업체 수·내 순위만 노출.
 *       타사 입찰가·업체명은 절대 반환하지 않는다({@link #buildRanking}).</li>
 *   <li>전 견적 공개({@link #awardView})는 <b>마감 후 + 발주자</b>만 가능.</li>
 *   <li>응찰은 <b>등록업체(company.registered=true)</b> + (초대 옥션이면) 초대된 업체만.</li>
 * </ul>
 */
@Service
public class AuctionService {

    private static final int MAX_LIMIT = 500;

    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(AuctionService.class);

    private final AuctionMapper mapper;
    private final com.zioinfo.kintex.settlement.SettlementService settlementService;
    private final com.zioinfo.kintex.work.notification.NotificationService notificationService;
    private final com.zioinfo.kintex.mail.MailService mailService;
    private final com.zioinfo.kintex.mail.MailProperties mailProperties;

    public AuctionService(AuctionMapper mapper,
                          com.zioinfo.kintex.settlement.SettlementService settlementService,
                          com.zioinfo.kintex.work.notification.NotificationService notificationService,
                          com.zioinfo.kintex.mail.MailService mailService,
                          com.zioinfo.kintex.mail.MailProperties mailProperties) {
        this.mapper = mapper;
        this.settlementService = settlementService;
        this.notificationService = notificationService;
        this.mailService = mailService;
        this.mailProperties = mailProperties;
    }

    // ── 목록(SCR-26) ──────────────────────────────────────────────────────────
    public List<AuctionSummary> list(String eventId, int page, int size) {
        int limit = Math.min(Math.max(size, 1), MAX_LIMIT);
        int offset = Math.max(page, 0) * limit;
        List<Map<String, Object>> rows = mapper.listAuctions(nullIfBlank(eventId), limit, offset);
        List<AuctionSummary> out = new ArrayList<>();
        for (Map<String, Object> r : rows) {
            out.add(toSummary(r));
        }
        return out;
    }

    // ── 상세·봉인 순위(SCR-27) ─────────────────────────────────────────────────
    public AuctionDetail getDetail(String id, KintexPrincipal principal) {
        Map<String, Object> r = mapper.findAuction(id);
        if (r == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        String eventId = str(r.get("eventId"));
        boolean closed = bool(r.get("closed"));
        boolean sealed = !closed;

        String myCompanyId = contractorCompanyId(principal, eventId);
        boolean orderer = isOrderer(principal, eventId);

        List<Map<String, Object>> rankRows = mapper.listRanking(id);
        List<RankRow> ranking = buildRanking(rankRows, myCompanyId, sealed);

        Integer myRank = null;
        Long myPrice = null;
        for (RankRow row : ranking) {
            if (row.isMe()) {
                myRank = row.rank();
                myPrice = row.price();
            }
        }

        boolean registered = myCompanyId != null && contractorRegistered(principal, eventId);
        boolean invitedOk = myCompanyId != null
                && (mapper.countInvites(id) == 0 || mapper.isInvited(id, myCompanyId) > 0);
        boolean canBid = !closed && registered && invitedOk;
        boolean canViewAward = closed && orderer;

        List<String> materials = splitMaterials(r.get("materials"));
        MaterialPackage pkg = materialPackage(str(r.get("materialPackageId")), materials);

        return new AuctionDetail(
                id, eventId, str(r.get("title")), str(r.get("category")),
                mapType(str(r.get("auctionType"))), status(r),
                intOr(r.get("round"), 1), intOr(r.get("dday"), 0), str(r.get("deadline")),
                intOr(r.get("bidderCount"), 0), lng(r.get("lowestPrice")),
                str(r.get("awardCriteria")),
                new Weights(intOr(r.get("weightPrice"), 0), intOr(r.get("weightReputation"), 0),
                        intOr(r.get("weightDelivery"), 0)),
                materials, pkg, sealed, myRank, myPrice, ranking, canBid, canViewAward,
                str(r.get("awardedCompany")), lng(r.get("finalPrice")));
    }

    /**
     * 봉인 마스킹. 노출 대상 = 내 응찰 + 현재 최저가(금액만, 위치 라벨). 그 외 타사는 금액·업체명 미반환.
     * (마감 후에도 상세 순위는 봉인 유지 — 전체 공개는 발주자 award-view 전용.)
     */
    private List<RankRow> buildRanking(List<Map<String, Object>> rows, String myCompanyId, boolean sealed) {
        List<RankRow> out = new ArrayList<>();
        int rank = 0;
        for (Map<String, Object> row : rows) {
            rank++;
            boolean isMe = myCompanyId != null && myCompanyId.equals(str(row.get("companyId")));
            boolean isLowest = rank == 1;
            long total = lngPrim(row.get("total"));
            // 봉인 규칙: 내 응찰과 최저가만 금액 공개. 그 외는 마스킹(업체명·금액 미반환).
            boolean reveal = isMe || isLowest;
            String alias = isMe ? "나의 응찰" : (isLowest ? "현재 최저가" : "업체 " + rank);
            out.add(new RankRow(rank, alias, isMe, isLowest, !reveal, reveal ? total : null));
        }
        return out;
    }

    // ── 응찰(견적서 제출) ──────────────────────────────────────────────────────
    @Transactional
    public BidResult placeBid(String id, KintexPrincipal principal, BidRequest req) {
        Map<String, Object> a = mapper.findAuction(id);
        if (a == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        if (bool(a.get("closed"))) {
            throw new ApiException(ErrorCode.CONFLICT); // 마감 후 응찰 불가(409)
        }
        boolean hasLines = req != null && req.lines() != null && !req.lines().isEmpty();
        if (req == null || (!hasLines && (req.total() == null || req.total() <= 0))) {
            throw new ApiException(ErrorCode.VALIDATION);
        }
        String eventId = str(a.get("eventId"));
        Map<String, Object> co = mapper.findContractorCompany(principal.userId(), eventId);
        if (co == null) {
            throw new ApiException(ErrorCode.FORBIDDEN); // 이 행사의 장치업체 멤버가 아님
        }
        if (!bool(co.get("registered"))) {
            throw new ApiException(ErrorCode.NOT_REGISTERED_COMPANY); // 미등록 업체 차단(403)
        }
        String companyId = str(co.get("companyId"));
        if (mapper.countInvites(id) > 0 && mapper.isInvited(id, companyId) == 0) {
            throw new ApiException(ErrorCode.FORBIDDEN); // 초대 옥션 — 미초대 차단
        }

        // ★ 서버 재계산(신뢰 경계): 라인아이템이 있으면 소계=Σ(수량×단가), 없으면 total 폴백.
        //   음수 수량·단가, 소계 0 이하는 QuotationCalc 가 검증(400). 산식은 QuotationCalcTest 로 고정.
        QuotationCalc.Totals t = hasLines
                ? QuotationCalc.fromLines(req.lines())
                : QuotationCalc.fromTotal(req.total());
        long subtotal = t.subtotal();
        long vat = t.vat();
        long total = subtotal; // 경쟁 순위 금액 = 부가세 별도 소계
        int leadDays = req.leadDays() == null ? 0 : req.leadDays();
        String linesJson = hasLines ? toJson(req.lines()) : null;

        Map<String, Object> p = new java.util.HashMap<>();
        p.put("auctionId", id);
        p.put("companyId", companyId);
        p.put("bidderUserId", principal.userId());
        p.put("round", intOr(a.get("round"), 1));
        p.put("subtotal", subtotal);
        p.put("vat", vat);
        p.put("total", total);
        p.put("leadDays", leadDays);
        p.put("validUntil", nullIfBlank(req.validUntil()));
        p.put("terms", req.terms());
        p.put("lines", linesJson);

        Map<String, Object> existing = mapper.findBid(id, companyId);
        int version;
        String bidId;
        if (existing == null) {
            bidId = "bid-" + UUID.randomUUID().toString().substring(0, 12);
            p.put("id", bidId);
            mapper.insertBid(p);
            version = 1;
        } else {
            bidId = str(existing.get("id"));
            mapper.updateBid(p); // version = version + 1
            version = intOr(existing.get("version"), 1) + 1;
        }

        int myRank = mapper.lowerBidCount(id, total) + 1;
        Long lowest = lng(mapper.findAuction(id).get("lowestPrice"));
        return new BidResult(bidId, intOr(a.get("round"), 1), subtotal, vat, total, subtotal + vat,
                version, myRank, lowest);
    }

    // ── 라운드 마감 처리(발주자) ────────────────────────────────────────────────
    /**
     * 현재 라운드를 즉시 마감(deadline=now)한다. 발주자만. 마감 후 봉인이 해제되어
     * 발주자는 award-view 로 전 견적을 비교하고 낙찰할 수 있다. 응찰은 마감 후 409로 차단된다.
     */
    @Transactional
    public AuctionSummary closeRound(String id, KintexPrincipal principal) {
        Map<String, Object> a = mapper.findAuction(id);
        if (a == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        if (!isOrderer(principal, str(a.get("eventId")))) {
            throw new ApiException(ErrorCode.FORBIDDEN);
        }
        if (bool(a.get("closed"))) {
            throw new ApiException(ErrorCode.CONFLICT); // 이미 마감됨
        }
        mapper.closeAuction(id);
        return toSummary(mapper.findAuction(id));
    }

    // ── 낙찰(Award) ────────────────────────────────────────────────────────────
    @Transactional
    public AwardResult award(String id, KintexPrincipal principal, AwardRequest req) {
        Map<String, Object> a = mapper.findAuction(id);
        if (a == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        if (!isOrderer(principal, str(a.get("eventId")))) {
            throw new ApiException(ErrorCode.FORBIDDEN);
        }
        if (!bool(a.get("closed"))) {
            throw new ApiException(ErrorCode.CONFLICT); // 마감 후에만 낙찰 가능(409)
        }
        if (req == null || req.bidId() == null || req.reason() == null || req.reason().isBlank()) {
            throw new ApiException(ErrorCode.VALIDATION); // 사유 필수
        }
        String bidAuction = mapper.findAuctionIdOfBid(req.bidId());
        if (bidAuction == null || !bidAuction.equals(id)) {
            throw new ApiException(ErrorCode.VALIDATION); // 이 옥션의 응찰이 아님
        }
        if (mapper.findAward(id) != null) {
            throw new ApiException(ErrorCode.CONFLICT); // 이미 낙찰됨
        }
        String awardId = "aw-" + UUID.randomUUID().toString().substring(0, 12);
        Map<String, Object> p = new java.util.HashMap<>();
        p.put("id", awardId);
        p.put("auctionId", id);
        p.put("bidId", req.bidId());
        p.put("reason", req.reason());
        p.put("awardedBy", principal.userId());
        mapper.insertAward(p);
        Map<String, Object> aw = mapper.findAward(id);

        // F084 옥션 수수료 → 정산 청구 자동 생성(멱등 — 같은 낙찰 중복 생성 없음). 실패해도 낙찰은 유효.
        try {
            settlementService.createAuctionFeeInvoice(
                    str(a.get("eventId")), awardId, id, str(a.get("title")),
                    str(aw.get("companyName")), lngPrim(aw.get("total")), principal);
        } catch (RuntimeException e) {
            // 수수료 청구 생성 실패는 낙찰 확정을 되돌리지 않는다(정산은 후속 재생성 가능). 스택트레이스 미노출.
        }

        return new AwardResult(awardId, req.bidId(), str(aw.get("companyName")), str(aw.get("awardedAt")));
    }

    // ── 발주자 비교(SCR-29 · 마감 후 봉인 해제) ────────────────────────────────
    public AwardView awardView(String id, KintexPrincipal principal) {
        Map<String, Object> a = mapper.findAuction(id);
        if (a == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        if (!isOrderer(principal, str(a.get("eventId")))) {
            throw new ApiException(ErrorCode.FORBIDDEN);
        }
        if (!bool(a.get("closed"))) {
            throw new ApiException(ErrorCode.FORBIDDEN); // 마감 전 봉인 유지 — 발주자도 전체 열람 불가
        }
        List<Map<String, Object>> rows = mapper.listQuotes(id);
        String criteria = str(a.get("awardCriteria"));
        Weights w = new Weights(intOr(a.get("weightPrice"), 0), intOr(a.get("weightReputation"), 0),
                intOr(a.get("weightDelivery"), 0));

        long minTotal = Long.MAX_VALUE;
        int minLead = Integer.MAX_VALUE;
        for (Map<String, Object> r : rows) {
            minTotal = Math.min(minTotal, lngPrim(r.get("total")));
            minLead = Math.min(minLead, intOr(r.get("leadDays"), 0));
        }

        List<Quote> quotes = new ArrayList<>();
        double bestScore = -1;
        int bestIdx = -1;
        // 1차: 스코어 계산
        double[] scores = new double[rows.size()];
        for (int i = 0; i < rows.size(); i++) {
            Map<String, Object> r = rows.get(i);
            long total = lngPrim(r.get("total"));
            int lead = intOr(r.get("leadDays"), 0);
            double rating = dbl(r.get("rating"), 4.0);
            // 종합점수 산식은 AuctionScoring(순수 함수)로 위임 — AuctionScoringTest 로 고정.
            scores[i] = AuctionScoring.score(criteria, w, total, minTotal, lead, minLead, rating);
            if (scores[i] > bestScore) {
                bestScore = scores[i];
                bestIdx = i;
            }
        }
        // 2차: DTO 조립
        for (int i = 0; i < rows.size(); i++) {
            Map<String, Object> r = rows.get(i);
            long total = lngPrim(r.get("total"));
            int lead = intOr(r.get("leadDays"), 0);
            boolean rec = i == bestIdx;
            double delta = minTotal > 0 ? round1((double) (total - minTotal) / minTotal * 100.0) : 0.0;
            quotes.add(new Quote(
                    str(r.get("bidId")), str(r.get("companyId")), str(r.get("companyName")),
                    "종합 " + (i + 1) + "위" + (rec ? " (추천)" : ""), rec,
                    total, delta, total == minTotal, lead, lead == minLead && lead > 0,
                    round1(dbl(r.get("rating"), 4.0)), scores[i]));
        }

        List<RiskBar> riskBars = riskBars(bestIdx >= 0 ? rows.get(bestIdx) : null);

        Map<String, Object> awRow = mapper.findAward(id);
        AwardResult awarded = awRow == null ? null
                : new AwardResult(str(awRow.get("id")), str(awRow.get("bidId")),
                str(awRow.get("companyName")), str(awRow.get("awardedAt")));

        return new AwardView(id, str(a.get("title")), rows.size(), criteria, w, quotes, riskBars, awarded);
    }

    private List<RiskBar> riskBars(Map<String, Object> recommended) {
        double rating = recommended == null ? 4.0 : dbl(recommended.get("rating"), 4.0);
        double claim = recommended == null ? 2.0 : dbl(recommended.get("claimRate"), 2.0);
        double delayRisk = round1(Math.max(claim, 0));
        double budgetRisk = round1(Math.max(0, (5.0 - rating) * 8.0));
        double quality = round1(rating / 5.0 * 100.0);
        List<RiskBar> bars = new ArrayList<>();
        bars.add(new RiskBar("시공 지연 확률", delayRisk, delayRisk <= 5 ? "success" : "ai"));
        bars.add(new RiskBar("예산 초과 위험", budgetRisk, budgetRisk <= 10 ? "success" : "ai"));
        bars.add(new RiskBar("자재 품질 신뢰도", quality, quality >= 90 ? "success" : "ai"));
        return bars;
    }

    // ── 개설(SCR-26) ───────────────────────────────────────────────────────────
    @Transactional
    public AuctionSummary create(KintexPrincipal principal, CreateAuctionRequest req) {
        if (req == null || req.eventId() == null || req.eventId().isBlank()
                || req.title() == null || req.title().isBlank()
                || req.deadline() == null || req.deadline().isBlank()) {
            throw new ApiException(ErrorCode.VALIDATION);
        }
        if (!isOrderer(principal, req.eventId())) {
            throw new ApiException(ErrorCode.FORBIDDEN);
        }
        String id = "AUC-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
        Weights w = req.weights() != null ? req.weights() : new Weights(60, 25, 15);
        List<String> mats = req.materials() == null ? List.of() : req.materials();

        Map<String, Object> p = new java.util.HashMap<>();
        p.put("id", id);
        p.put("eventId", req.eventId());
        p.put("title", req.title());
        p.put("category", req.category() == null ? "전시디자인설치" : req.category());
        p.put("auctionType", "RFQ".equalsIgnoreCase(req.type()) ? "rfq" : "reverse");
        p.put("awardCriteria", "lowest".equals(req.awardCriteria()) ? "lowest" : "comprehensive");
        p.put("weightPrice", w.price());
        p.put("weightReputation", w.reputation());
        p.put("weightDelivery", w.delivery());
        p.put("round", req.round() == null ? 1 : req.round());
        p.put("deadline", req.deadline());
        p.put("materialPackageId", nullIfBlank(req.materialPackageId()));
        p.put("materials", String.join(",", mats));
        p.put("accent", "primary");
        p.put("createdBy", principal.userId());
        mapper.insertAuction(p);

        // 초대: 등록업체(registered=true)만 통과 — 미등록 id는 서버가 걸러낸다(게이트 불변).
        if (req.invitedCompanyIds() != null && !req.invitedCompanyIds().isEmpty()) {
            List<String> registeredIds = mapper.filterRegistered(req.invitedCompanyIds());
            for (String cid : registeredIds) {
                Map<String, Object> inv = new java.util.HashMap<>();
                inv.put("id", "ai-" + UUID.randomUUID().toString().substring(0, 12));
                inv.put("auctionId", id);
                inv.put("companyId", cid);
                mapper.insertInvite(inv);
            }
        }

        // 개설 통지 — 대상 공사업체(초대 있으면 초대 업체, 없으면 행사 CONTRACTOR 전체)에게
        //   ①§5B 인앱 알림 ②이메일(MAIL_ENABLED=false면 인앱만). 통지 실패는 개설을 되돌리지 않는다.
        notifyAuctionOpened(id, req.eventId(), req.title(), req.deadline());

        return toSummary(mapper.findAuction(id));
    }

    /** 개설 통지 발행(인앱 + 이메일). 실패 격리 — 알림 오류가 개설 트랜잭션을 깨지 않는다. */
    private void notifyAuctionOpened(String auctionId, String eventId, String title, String deadline) {
        try {
            List<Map<String, Object>> targets = mapper.countInvites(auctionId) > 0
                    ? mapper.listInvitedContractorUsers(auctionId)
                    : mapper.listEventContractorUsers(eventId);
            if (targets == null || targets.isEmpty()) {
                return;
            }
            String eventName = mapper.findEventName(eventId);
            if (eventName == null || eventName.isBlank()) {
                eventName = "행사";
            }
            String link = "/auctions/" + auctionId;
            String inAppMsg = "응찰 마감 " + shortDeadline(deadline) + " · " + eventName;
            String mailLink = mailProperties.getWebBaseUrl() + link;
            String subject = "[KINTEX] 공사 옥션 개설 안내 · " + title;
            String html = com.zioinfo.kintex.mail.MailTemplates.auctionOpened(
                    eventName, title, shortDeadline(deadline), mailLink);

            for (Map<String, Object> u : targets) {
                String userId = str(u.get("userId"));
                if (userId == null) {
                    continue;
                }
                // ① 인앱 알림(발송 채널 단일화 §7-6)
                try {
                    notificationService.notify(userId, eventId, "MESSAGE",
                            "새 공사 옥션 개설: " + title, inAppMsg, link);
                } catch (RuntimeException e) {
                    log.warn("옥션 인앱 통지 실패(무시): auction={} reason={}", auctionId, e.getClass().getSimpleName());
                }
                // ② 이메일(@Async·실패 격리 — MAIL_ENABLED=false면 내부에서 스킵)
                String email = str(u.get("email"));
                if (email != null && !email.isBlank()) {
                    mailService.send("AUCTION_OPEN", auctionId, email, subject, html);
                }
            }
        } catch (RuntimeException e) {
            log.warn("옥션 개설 통지 처리 실패(개설은 정상): auction={} reason={}",
                    auctionId, e.getClass().getSimpleName());
        }
    }

    /** deadline(ISO 또는 원문)에서 날짜/시각 앞부분만 짧게 — 통지 표기용(파싱 실패 시 원문). */
    private static String shortDeadline(String deadline) {
        if (deadline == null || deadline.isBlank()) {
            return "-";
        }
        String s = deadline.trim();
        int t = s.indexOf('T');
        if (t > 0) {
            String date = s.substring(0, t);
            String time = s.length() >= t + 6 ? s.substring(t + 1, t + 6) : "";
            return time.isEmpty() ? date : date + " " + time;
        }
        return s.length() > 16 ? s.substring(0, 16) : s;
    }

    // ── 업체 포털 대시보드(SCR-38) ─────────────────────────────────────────────
    public ContractorDashboard contractorDashboard(KintexPrincipal principal) {
        Map<String, Object> co = mapper.findUserCompany(principal.userId());
        if (co == null) {
            return new ContractorDashboard(null, List.of(
                    new Kpi("수주 부스", 0, "normal"), new Kpi("진행 옥션", 0, "normal"),
                    new Kpi("응찰 대기", 0, "normal"), new Kpi("시공 진행", 0, "success"),
                    new Kpi("마감 임박", 0, "error")),
                    List.of(), List.of(), List.of());
        }
        String companyId = str(co.get("companyId"));
        String companyName = str(co.get("companyName"));

        List<Map<String, Object>> bidRows = mapper.listMyBids(companyId);
        List<MyBid> myBids = new ArrayList<>();
        int liveCount = 0, imminent = 0;
        for (Map<String, Object> b : bidRows) {
            long myPrice = lngPrim(b.get("myPrice"));
            int dday = intOr(b.get("dday"), 0);
            boolean closed = bool(b.get("closed"));
            int rank = mapper.lowerBidCount(str(b.get("auctionId")), myPrice) + 1;
            myBids.add(new MyBid(str(b.get("auctionId")), str(b.get("auctionTitle")), myPrice, rank, dday));
            if (!closed) {
                liveCount++;
                if (dday >= 0 && dday <= 1) imminent++;
            }
        }

        List<Map<String, Object>> awRows = mapper.listMyAwardedBooths(companyId);
        List<AwardedBooth> booths = new ArrayList<>();
        for (Map<String, Object> b : awRows) {
            String title = str(b.get("auctionTitle"));
            booths.add(new AwardedBooth(str(b.get("auctionId")), str(b.get("eventName")),
                    title, boothCode(title), str(b.get("category")), null, "시공"));
        }

        List<FeedItem> feed = new ArrayList<>();
        for (AwardedBooth b : booths) {
            feed.add(new FeedItem("낙찰 확정: " + b.name(), b.event() + " · 발주 전환 대기", "ok"));
        }
        for (MyBid b : myBids) {
            if (b.myRank() != null && b.myRank() == 1) {
                feed.add(new FeedItem("현재 1위 응찰: " + b.auctionTitle(), "D-" + Math.max(b.dday(), 0), "info"));
            }
        }

        List<Kpi> kpis = List.of(
                new Kpi("수주 부스", booths.size(), "normal"),
                new Kpi("진행 옥션", liveCount, "normal"),
                new Kpi("응찰", myBids.size(), "normal"),
                new Kpi("시공 진행", booths.size(), "success"),
                new Kpi("마감 임박", imminent, imminent > 0 ? "error" : "normal"));

        return new ContractorDashboard(companyName, kpis, booths, myBids, feed);
    }

    // ── 권한/매핑 헬퍼 ─────────────────────────────────────────────────────────
    private boolean isOrderer(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.EXHIBITOR || role == EventRole.HALL_MANAGER;
    }

    private String contractorCompanyId(KintexPrincipal principal, String eventId) {
        if (principal == null) return null;
        Map<String, Object> co = mapper.findContractorCompany(principal.userId(), eventId);
        return co == null ? null : str(co.get("companyId"));
    }

    private boolean contractorRegistered(KintexPrincipal principal, String eventId) {
        Map<String, Object> co = mapper.findContractorCompany(principal.userId(), eventId);
        return co != null && bool(co.get("registered"));
    }

    private AuctionSummary toSummary(Map<String, Object> r) {
        return new AuctionSummary(
                str(r.get("id")), str(r.get("eventId")), str(r.get("title")), str(r.get("category")),
                mapType(str(r.get("auctionType"))), status(r), intOr(r.get("round"), 1), intOr(r.get("dday"), 0),
                intOr(r.get("bidderCount"), 0), lng(r.get("lowestPrice")), str(r.get("awardedCompany")),
                lng(r.get("finalPrice")), splitMaterials(r.get("materials")), str(r.get("accent")));
    }

    /** 표시 상태: 낙찰됨→정산중, 마감경과·미낙찰→마감, 그 외→진행중. */
    private String status(Map<String, Object> r) {
        if (r.get("awardBidId") != null) return "정산중";
        if (bool(r.get("closed"))) return "마감";
        return "진행중";
    }

    private String mapType(String t) {
        return "rfq".equalsIgnoreCase(t) ? "RFQ" : "역경매";
    }

    private MaterialPackage materialPackage(String pkgId, List<String> mats) {
        // 실제 URL 은 M2~M5 자료 스토리지 연동(후속). 현재는 존재 여부만 표기(없으면 null → 플레이스홀더).
        return new MaterialPackage(
                mats.contains("layout") ? placeholder(pkgId, "layout") : null,
                mats.contains("design") ? placeholder(pkgId, "design") : null,
                mats.contains("boq") ? placeholder(pkgId, "boq") : null,
                mats.contains("aiimage") ? placeholder(pkgId, "aiimage") : null);
    }

    /** 제목에서 부스 코드 추출(예: "A-102 독립부스 시공" → "A-102"). 없으면 앞 단어. */
    private String boothCode(String title) {
        if (title == null || title.isBlank()) return "";
        java.util.regex.Matcher m = java.util.regex.Pattern.compile("([A-Za-z]-?\\d{1,4})").matcher(title);
        if (m.find()) return m.group(1);
        return title.split("\\s+")[0];
    }

    private String placeholder(String pkgId, String kind) {
        return pkgId == null ? null : "/api/materials/" + pkgId + "/" + kind;
    }

    private List<String> splitMaterials(Object o) {
        String s = str(o);
        if (s == null || s.isBlank()) return List.of();
        return Arrays.stream(s.split(",")).map(String::trim).filter(x -> !x.isEmpty()).toList();
    }

    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 boolean bool(Object o) {
        if (o instanceof Boolean b) return b;
        return o != null && Boolean.parseBoolean(String.valueOf(o));
    }

    private static Integer intOr(Object o, int def) {
        if (o == null) return def;
        if (o instanceof Number n) return n.intValue();
        try {
            return Integer.valueOf(String.valueOf(o));
        } catch (NumberFormatException e) {
            return def;
        }
    }

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

    private static long lngPrim(Object o) {
        Long v = lng(o);
        return v == null ? 0L : v;
    }

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

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

    private static final com.fasterxml.jackson.databind.ObjectMapper JSON =
            new com.fasterxml.jackson.databind.ObjectMapper();

    private static String toJson(Object o) {
        try {
            return JSON.writeValueAsString(o);
        } catch (Exception e) {
            return null; // 직렬화 실패 시 라인아이템 스냅샷 생략(집계는 컬럼값 사용)
        }
    }
}
