package com.zioinfo.kintex.visitor;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zioinfo.kintex.ai.AiTextRouter;
import com.zioinfo.kintex.ai.AiTextRouter.AiResult;
import com.zioinfo.kintex.common.PageResponse;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.visitor.dto.VisitorDtos.*;
import com.zioinfo.kintex.visitor.scoring.LeadScorer;
import com.zioinfo.kintex.visitor.scoring.LeadSignals;
import com.zioinfo.kintex.visitor.scoring.ScoreResult;
import org.springframework.stereotype.Service;

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

/**
 * M10 관람객·리드 서비스 — 집계·목록(마스킹)·공개 사전등록.
 * <p>★ PII 불변: 매퍼는 이미 마스킹된 필드만 반환한다. 서비스는 원문 PII 를 응답 DTO 로 옮기지 않는다.
 * 원문은 오직 등록 INSERT 파라미터로만 사용하고 어떤 반환/로그에도 담지 않는다.
 */
@Service
public class VisitorService {

    private static final ObjectMapper JSON = new ObjectMapper();

    /** 유형 표시 라벨·색상(프론트 도넛 팔레트와 정렬). */
    private static final Map<String, String[]> TYPE_META = Map.of(
            "visitor", new String[]{"관람객", "#0066B3"},
            "buyer", new String[]{"바이어", "#6D4AFF"},
            "vip", new String[]{"VIP", "#0E8A5F"});

    private final VisitorMapper mapper;
    private final LeadScorer leadScorer;
    private final AiTextRouter aiRouter;

    public VisitorService(VisitorMapper mapper, LeadScorer leadScorer, AiTextRouter aiRouter) {
        this.mapper = mapper;
        this.leadScorer = leadScorer;
        this.aiRouter = aiRouter;
    }

    // ── SCR-30 요약 ──
    public VisitorSummary summary(String eventId) {
        Map<String, Object> c = orEmpty(mapper.findSummaryCounts(eventId));
        Map<String, Object> lc = orEmpty(mapper.findLeadCounts(eventId));

        long total = lng(c.get("total"));
        long checkedIn = lng(c.get("checkedIn"));
        long waiting = lng(c.get("waiting"));
        long cancelled = lng(c.get("cancelled"));
        long buyers = lng(c.get("buyers"));
        long leads = lng(lc.get("total"));

        List<Kpi> kpis = new ArrayList<>();
        kpis.add(new Kpi("사전등록", fmt(total), null, null, null, null));
        kpis.add(new Kpi("체크인", fmt(checkedIn), null, null, null,
                total > 0 ? "진행률 " + pct(checkedIn, total) : null));
        long noShowBase = checkedIn + waiting + cancelled;
        kpis.add(new Kpi("노쇼/취소", fmt(waiting + cancelled), null, "down", null,
                noShowBase > 0 ? pct(waiting + cancelled, noShowBase) : null));
        kpis.add(new Kpi("바이어 비중", total > 0 ? pct(buyers, total) : "0%", null, null, null, "목표 30%"));
        kpis.add(new Kpi("리드 생성", fmt(leads), null, null, true, "AI 스코어링 대상"));

        return new VisitorSummary(kpis, buildTrend(eventId), buildTypes(eventId, total), buildForms(eventId));
    }

    private List<TrendPoint> buildTrend(String eventId) {
        List<Map<String, Object>> rows = mapper.findDailyTrend(eventId);
        List<TrendPoint> out = new ArrayList<>();
        long cumReg = 0, cumChk = 0;
        if (rows != null) {
            for (Map<String, Object> r : rows) {
                cumReg += lng(r.get("regCount"));
                cumChk += lng(r.get("checkinCount"));
                out.add(new TrendPoint(str(r.get("day")), cumReg, cumChk));
            }
        }
        return out;
    }

    private List<TypeSlice> buildTypes(String eventId, long total) {
        List<Map<String, Object>> rows = mapper.findTypeCounts(eventId);
        List<TypeSlice> out = new ArrayList<>();
        if (rows != null) {
            for (Map<String, Object> r : rows) {
                String type = str(r.get("type"));
                long cnt = lng(r.get("cnt"));
                String[] meta = TYPE_META.getOrDefault(type, new String[]{type, "#98A2B3"});
                int value = total > 0 ? (int) Math.round(cnt * 100.0 / total) : 0;
                out.add(new TypeSlice(meta[0], value, meta[1]));
            }
        }
        return out;
    }

    /** 등록 폼 — 유형 카운트에서 파생(폼 마스터 부재 시). */
    private List<FormItem> buildForms(String eventId) {
        List<Map<String, Object>> rows = mapper.findTypeCounts(eventId);
        Map<String, Long> byType = new java.util.HashMap<>();
        if (rows != null) {
            for (Map<String, Object> r : rows) {
                byType.put(str(r.get("type")), lng(r.get("cnt")));
            }
        }
        List<FormItem> out = new ArrayList<>();
        out.add(new FormItem("general", "일반 참관객", true, byType.getOrDefault("visitor", 0L)));
        out.add(new FormItem("buyer", "비즈니스 바이어", true, byType.getOrDefault("buyer", 0L)));
        out.add(new FormItem("vip", "VIP 초청", byType.getOrDefault("vip", 0L) > 0, byType.getOrDefault("vip", 0L)));
        return out;
    }

    // ── SCR-30 목록(마스킹) ──
    public PageResponse<VisitorListItem> visitors(String eventId, int page, int size) {
        int limit = clamp(size, 1, 200);
        int offset = Math.max(page, 0) * limit;
        List<Map<String, Object>> rows = mapper.findVisitorPage(eventId, limit, offset);
        List<VisitorListItem> items = new ArrayList<>(rows == null ? 0 : rows.size());
        if (rows != null) {
            for (Map<String, Object> r : rows) {
                items.add(new VisitorListItem(
                        str(r.get("id")), str(r.get("nameMasked")), str(r.get("type")),
                        str(r.get("company")), str(r.get("registeredAt")), str(r.get("checkin")),
                        bool(r.get("badgeIssued"))));
            }
        }
        return PageResponse.of(items, Math.max(page, 0), limit, mapper.countVisitors(eventId));
    }

    // ── SCR-32 리드(마스킹 + AI 저장값) ──
    public PageResponse<LeadItem> leads(String eventId, String boothId, int page, int size) {
        int limit = clamp(size, 1, 200);
        int offset = Math.max(page, 0) * limit;
        String bid = (boothId == null || boothId.isBlank()) ? null : boothId;
        List<Map<String, Object>> rows = mapper.findLeadPage(eventId, bid, limit, offset);
        List<LeadItem> items = new ArrayList<>(rows == null ? 0 : rows.size());
        if (rows != null) {
            for (Map<String, Object> r : rows) {
                items.add(new LeadItem(
                        str(r.get("id")), str(r.get("nameMasked")), str(r.get("role")), str(r.get("company")),
                        str(r.get("product")), intVal(r.get("interest")), intVal(r.get("score")),
                        str(r.get("collectedAt")), str(r.get("phoneMasked")), str(r.get("emailMasked")),
                        parseStrings(r.get("aiReasons")), parseActivity(r.get("activity")),
                        str(r.get("followupDraft")), bool(r.get("aiGenerated"))));
            }
        }
        return PageResponse.of(items, Math.max(page, 0), limit, mapper.countLeads(eventId, bid));
    }

    // ── 공개 사전등록 ──
    public RegisterResult register(String eventId, RegisterRequest req) {
        String name = trimOrNull(req.name());
        String email = trimOrNull(req.email());
        String phone = trimOrNull(req.phone());
        if (name == null || email == null || !email.contains("@")) {
            throw new ApiException(ErrorCode.VALIDATION, "이름과 유효한 이메일을 입력해 주세요.");
        }
        if (req.agreePrivacy() == null || !req.agreePrivacy()) {
            throw new ApiException(ErrorCode.VALIDATION, "개인정보 수집·이용에 동의해야 등록할 수 있습니다.");
        }
        if (mapper.eventExists(eventId) == 0) {
            throw new ApiException(ErrorCode.NOT_FOUND, "존재하지 않는 행사입니다.");
        }
        // 중복 이메일 → 기존 등록 재반환(멱등)
        Map<String, Object> existing = mapper.findByEventEmail(eventId, email);
        if (existing != null && existing.get("registrationId") != null) {
            return new RegisterResult(str(existing.get("registrationId")), str(existing.get("badgeCode")));
        }
        String type = normalizeType(req.visitorType());
        String id = "vr-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20);
        String badgeCode = "KTX-V-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase();
        try {
            mapper.insertRegistration(id, eventId, name, phone, email, type,
                    Boolean.TRUE.equals(req.agreePrivacy()), Boolean.TRUE.equals(req.agreeMarketing()), badgeCode);
        } catch (org.springframework.dao.DuplicateKeyException dup) {
            // 동시 요청 경합 — 기존 등록 재조회
            Map<String, Object> again = mapper.findByEventEmail(eventId, email);
            if (again != null && again.get("registrationId") != null) {
                return new RegisterResult(str(again.get("registrationId")), str(again.get("badgeCode")));
            }
            throw new ApiException(ErrorCode.CONFLICT, "이미 등록된 이메일입니다.");
        }
        return new RegisterResult(id, badgeCode);
    }

    // ── SCR-31 현장 체크인 ──

    /**
     * QR 토큰(우선) 또는 등록ID 로 체크인. 이미 완료된 건은 재집계 없이 {@code alreadyCheckedIn=true} 로 반환한다(중복 방지).
     * 체크인 판정은 매퍼의 조건부 UPDATE(상태 != 'done' 인 행만 갱신)로 원자적으로 수행된다.
     */
    public CheckinResult checkin(String eventId, CheckinRequest req) {
        String token = trimOrNull(req == null ? null : req.token());
        String regId = trimOrNull(req == null ? null : req.registrationId());
        if (token == null && regId == null) {
            throw new ApiException(ErrorCode.VALIDATION, "QR 토큰 또는 등록번호가 필요합니다.");
        }
        Map<String, Object> r = mapper.findForCheckin(eventId, token, regId);
        if (r == null || r.get("registrationId") == null) {
            throw new ApiException(ErrorCode.NOT_FOUND, "등록 정보를 찾을 수 없습니다. 현장 등록을 진행해 주세요.");
        }
        String rid = str(r.get("registrationId"));
        boolean wasDone = "done".equals(str(r.get("checkinState")));
        int updated = wasDone ? 0 : mapper.markCheckedIn(eventId, rid);
        boolean alreadyCheckedIn = wasDone || updated == 0; // 경합 시에도 0 → 이미 완료로 간주
        // 갱신 후 체크인 시각 재조회(방금 체크인이면 now 반영).
        Map<String, Object> after = mapper.findForCheckin(eventId, null, rid);
        Map<String, Object> src = after != null ? after : r;
        return new CheckinResult(rid, str(src.get("nameMasked")), str(src.get("type")), str(src.get("company")),
                str(src.get("badgeCode")), bool(src.get("badgeIssued")), str(src.get("checkinState")),
                alreadyCheckedIn, str(src.get("checkinAt")));
    }

    /** 현장 수동 검색(이름/연락처/배지코드/등록번호). 마스킹 결과만 반환. */
    public List<CheckinSearchItem> searchForCheckin(String eventId, String q) {
        String query = trimOrNull(q);
        if (query == null || query.length() < 2) {
            throw new ApiException(ErrorCode.VALIDATION, "검색어는 2자 이상 입력해 주세요.");
        }
        String digits = query.replaceAll("\\D", "");
        List<Map<String, Object>> rows = mapper.searchRegistrations(eventId, query, digits);
        List<CheckinSearchItem> out = new ArrayList<>(rows == null ? 0 : rows.size());
        if (rows != null) {
            for (Map<String, Object> r : rows) {
                out.add(new CheckinSearchItem(str(r.get("registrationId")), str(r.get("nameMasked")),
                        str(r.get("type")), str(r.get("company")), str(r.get("phoneMasked")),
                        str(r.get("badgeCode")), str(r.get("checkinState"))));
            }
        }
        return out;
    }

    /** 실시간 입장 집계 — 사전등록/체크인/장내 추정 + 시간대별 버킷. */
    public CheckinStats checkinStats(String eventId) {
        Map<String, Object> c = orEmpty(mapper.findSummaryCounts(eventId));
        long total = lng(c.get("total"));
        long checkedIn = lng(c.get("checkedIn"));
        List<Map<String, Object>> hours = mapper.findCheckinByHour(eventId);
        List<HourBucket> byHour = new ArrayList<>();
        if (hours != null) {
            for (Map<String, Object> h : hours) {
                byHour.add(new HourBucket(str(h.get("hour")), lng(h.get("count"))));
            }
        }
        // 장내 추정: 체크인 인원의 92%(단순 추정 — 퇴장 미추적 환경 근사).
        long inside = Math.round(checkedIn * 0.92);
        return new CheckinStats(total, checkedIn, inside, byHour);
    }

    /** 배지 재발급 — 새 배지코드+QR 토큰 부여(민감정보 아님). */
    public BadgeReissueResult reissueBadge(String eventId, String registrationId) {
        String badgeCode = "KTX-V-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase();
        String qrToken = "QR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20).toUpperCase();
        int n = mapper.reissueBadge(eventId, registrationId, badgeCode, qrToken);
        if (n == 0) {
            throw new ApiException(ErrorCode.NOT_FOUND, "등록 정보를 찾을 수 없습니다.");
        }
        return new BadgeReissueResult(registrationId, badgeCode, true);
    }

    // ── SCR-32 리드 수집(규칙 엔진 스코어) ──

    /** 참가업체 부스 리드 수집. 스코어는 서버 규칙 엔진이 산출(클라이언트 값 신뢰 안 함). AI 보정 지점은 인터페이스로 열어둠. */
    public LeadCollectResult collectLead(String eventId, LeadCollectRequest req) {
        String name = trimOrNull(req.name());
        if (name == null) {
            throw new ApiException(ErrorCode.VALIDATION, "리드 이름을 입력해 주세요.");
        }
        if (req.agreePrivacy() != null && !req.agreePrivacy()) {
            throw new ApiException(ErrorCode.VALIDATION, "개인정보 수집 동의가 없는 리드는 저장할 수 없습니다.");
        }
        int interest = clamp(req.interest() == null ? 3 : req.interest(), 1, 5);
        LeadSignals signals = new LeadSignals(
                interest,
                req.dwellRatioPct() == null ? 100 : Math.max(req.dwellRatioPct(), 0),
                req.revisitCount() == null ? 0 : Math.max(req.revisitCount(), 0),
                Boolean.TRUE.equals(req.decisionMaker()),
                req.docDownloads() == null ? 0 : Math.max(req.docDownloads(), 0));
        ScoreResult sr = leadScorer.score(signals);

        String id = "ld-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20);
        String reasonsJson;
        try {
            reasonsJson = JSON.writeValueAsString(sr.reasons());
        } catch (Exception e) {
            reasonsJson = "[]";
        }
        mapper.insertLead(id, eventId, trimOrNull(req.boothId()), trimOrNull(req.exhibitorId()),
                name, trimOrNull(req.phone()), trimOrNull(req.email()), trimOrNull(req.role()),
                trimOrNull(req.company()), trimOrNull(req.product()), interest, sr.score(), reasonsJson,
                trimOrNull(req.note()), req.agreePrivacy() == null || req.agreePrivacy());
        return new LeadCollectResult(id, sr.score(), sr.reasons(), sr.source());
    }

    // ── SCR-32 리드 AI 재계산(F051) ──

    /**
     * 리드 스코어를 AI({@link AiTextRouter})로 재계산한다. <b>비-PII 신호만</b>(직급·소속·관심제품·관심도·활동 로그)을
     * 프롬프트로 사용하며 이름/연락처/이메일은 절대 전송하지 않는다. AI 는 JSON {score,reasons} 로만 응답하도록 지시한다.
     *
     * <p><b>폴백(무회귀)</b>: AI 미가용/실패/파싱 실패 시 <b>기존 규칙 스코어를 그대로 유지</b>하고
     * {@code ai_generated=false}·source="rule" 로 정직하게 표기한다(환각 방지). 스코어를 임의로 낮추지 않는다.
     */
    public LeadRescoreResult rescoreLead(String eventId, String leadId) {
        Map<String, Object> r = mapper.findLeadForRescore(eventId, leadId);
        if (r == null) {
            throw new ApiException(ErrorCode.NOT_FOUND, "리드를 찾을 수 없습니다.");
        }
        int currentScore = clamp(intVal(r.get("score")), 0, 100);
        int interest = clamp(intVal(r.get("interest")), 1, 5);
        List<String> existingReasons = parseStrings(r.get("aiReasons"));

        String prompt = buildRescorePrompt(r, interest);
        AiResult ai = aiRouter.generate(prompt, 512);

        if (ai.usable()) {
            Parsed parsed = parseRescore(ai.text());
            if (parsed != null) {
                int score = clamp(parsed.score(), 0, 100);
                List<String> reasons = parsed.reasons().isEmpty() ? existingReasons : parsed.reasons();
                String reasonsJson = toJson(reasons);
                mapper.updateLeadScore(eventId, leadId, score, reasonsJson, true);
                return new LeadRescoreResult(leadId, score, reasons, "ai", true, false, ai.provider());
            }
        }
        // 폴백: 기존 규칙 스코어 유지(스코어·근거 불변), AI 생성 아님으로 표기.
        mapper.updateLeadScore(eventId, leadId, currentScore, toJson(existingReasons), false);
        return new LeadRescoreResult(leadId, currentScore, existingReasons, "rule", false, true, ai.provider());
    }

    /** 재계산 프롬프트 — 비-PII 신호만 + JSON 전용 출력 지시(결정론·환각 억제). */
    private static String buildRescorePrompt(Map<String, Object> r, int interest) {
        StringBuilder sb = new StringBuilder();
        sb.append("당신은 B2B 전시회 리드 스코어링 분석가다. 아래 리드 신호만으로 구매 전환 가능성을 0~100 정수로 평가하라.\n");
        sb.append("개인 식별정보(이름/연락처/이메일)는 주어지지 않으며, 추측하지 마라. 신호에 없는 사실을 지어내지 마라.\n\n");
        sb.append("[리드 신호]\n");
        sb.append("- 직급/역할: ").append(nz(str(r.get("role")))).append('\n');
        sb.append("- 소속: ").append(nz(str(r.get("company")))).append('\n');
        sb.append("- 관심 제품: ").append(nz(str(r.get("product")))).append('\n');
        sb.append("- 상담원 관심도(1~5): ").append(interest).append('\n');
        sb.append("- 활동 로그(JSON): ").append(nz(str(r.get("activity")))).append('\n');
        sb.append("- 기존 근거(JSON): ").append(nz(str(r.get("aiReasons")))).append('\n');
        sb.append("\n오직 아래 JSON 형식으로만 답하라(설명·마크다운·코드펜스 금지):\n");
        sb.append("{\"score\": <0~100 정수>, \"reasons\": [\"근거1\", \"근거2\"]}\n");
        return sb.toString();
    }

    /** AI 응답(JSON)에서 score·reasons 추출. 파싱 실패 시 null(→ 규칙 폴백). */
    private Parsed parseRescore(String text) {
        if (text == null || text.isBlank()) {
            return null;
        }
        String json = extractJsonObject(text);
        if (json == null) {
            return null;
        }
        try {
            JsonNode root = JSON.readTree(json);
            JsonNode scoreNode = root.get("score");
            if (scoreNode == null || !scoreNode.isNumber()) {
                return null;
            }
            int score = scoreNode.asInt();
            List<String> reasons = new ArrayList<>();
            JsonNode rs = root.get("reasons");
            if (rs != null && rs.isArray()) {
                for (JsonNode n : rs) {
                    String v = n.asText(null);
                    if (v != null && !v.isBlank()) {
                        reasons.add(v.trim());
                    }
                    if (reasons.size() >= 6) {
                        break;
                    }
                }
            }
            return new Parsed(score, reasons);
        } catch (Exception e) {
            return null;
        }
    }

    /** 텍스트에서 첫 번째 균형 잡힌 JSON 객체를 추출(코드펜스·전후 잡음 방어). */
    private static String extractJsonObject(String text) {
        int start = text.indexOf('{');
        if (start < 0) {
            return null;
        }
        int depth = 0;
        boolean inStr = false;
        boolean esc = false;
        for (int i = start; i < text.length(); i++) {
            char c = text.charAt(i);
            if (inStr) {
                if (esc) {
                    esc = false;
                } else if (c == '\\') {
                    esc = true;
                } else if (c == '"') {
                    inStr = false;
                }
            } else if (c == '"') {
                inStr = true;
            } else if (c == '{') {
                depth++;
            } else if (c == '}') {
                depth--;
                if (depth == 0) {
                    return text.substring(start, i + 1);
                }
            }
        }
        return null;
    }

    private String toJson(List<String> list) {
        try {
            return JSON.writeValueAsString(list == null ? List.of() : list);
        } catch (Exception e) {
            return "[]";
        }
    }

    private static String nz(String s) {
        return s == null ? "(없음)" : s;
    }

    /** 재계산 파싱 결과(내부). */
    private record Parsed(int score, List<String> reasons) {
    }

    /** 리드 CSV(마스킹 필드만). RFC4180 유사 이스케이프. UTF-8 BOM 은 컨트롤러가 부여. */
    public String leadsCsv(String eventId, String boothId) {
        String bid = (boothId == null || boothId.isBlank()) ? null : boothId;
        List<Map<String, Object>> rows = mapper.findLeadsForCsv(eventId, bid);
        StringBuilder sb = new StringBuilder();
        sb.append("이름(마스킹),소속,직급,관심제품,관심도,AI스코어,연락처(마스킹),이메일(마스킹),수집시각\r\n");
        if (rows != null) {
            for (Map<String, Object> r : rows) {
                sb.append(csv(str(r.get("nameMasked")))).append(',')
                        .append(csv(str(r.get("company")))).append(',')
                        .append(csv(str(r.get("role")))).append(',')
                        .append(csv(str(r.get("product")))).append(',')
                        .append(intVal(r.get("interest"))).append(',')
                        .append(intVal(r.get("score"))).append(',')
                        .append(csv(str(r.get("phoneMasked")))).append(',')
                        .append(csv(str(r.get("emailMasked")))).append(',')
                        .append(csv(str(r.get("collectedAt")))).append("\r\n");
            }
        }
        return sb.toString();
    }

    private static String csv(String v) {
        if (v == null) return "";
        if (v.contains(",") || v.contains("\"") || v.contains("\n") || v.contains("\r")) {
            return '"' + v.replace("\"", "\"\"") + '"';
        }
        return v;
    }

    // ── helpers ──
    private static String normalizeType(String t) {
        if (t == null) return "visitor";
        String v = t.trim().toLowerCase();
        return switch (v) {
            case "buyer", "vip", "visitor" -> v;
            default -> "visitor";
        };
    }

    private List<String> parseStrings(Object json) {
        if (json == null) return List.of();
        try {
            return JSON.readValue(String.valueOf(json), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {
            });
        } catch (Exception e) {
            return List.of();
        }
    }

    private List<Activity> parseActivity(Object json) {
        if (json == null) return List.of();
        try {
            List<Map<String, Object>> raw = JSON.readValue(String.valueOf(json),
                    new com.fasterxml.jackson.core.type.TypeReference<List<Map<String, Object>>>() {
                    });
            List<Activity> out = new ArrayList<>(raw.size());
            for (Map<String, Object> m : raw) {
                out.add(new Activity(str(m.get("text")), str(m.get("at"))));
            }
            return out;
        } catch (Exception e) {
            return List.of();
        }
    }

    private static Map<String, Object> orEmpty(Map<String, Object> m) {
        return m == null ? Map.of() : m;
    }

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

    private static String trimOrNull(String s) {
        if (s == null) return null;
        String t = s.trim();
        return t.isEmpty() ? null : t;
    }

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

    private static int intVal(Object o) {
        return (int) lng(o);
    }

    private static boolean bool(Object o) {
        return o instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(o));
    }

    private static int clamp(int v, int min, int max) {
        return Math.max(min, Math.min(v, max));
    }

    private static String fmt(long v) {
        return String.format("%,d", v);
    }

    private static String pct(long part, long whole) {
        if (whole <= 0) return "0%";
        return Math.round(part * 100.0 / whole) + "%";
    }
}
