package com.zioinfo.kintex.marketing;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.marketing.dto.MarketingDtos.*;
import org.springframework.stereotype.Service;

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

/**
 * M12 EDM·캠페인 · 스폰서십 서비스.
 * <p>캠페인 발송(G-04): 수신동의 필터 → 발송 상한/rate cap → 테스트 모드(기본 ON) → 자체 SMTP 개별 발송 → 집계.
 * 수신자 원문/PII 는 응답·로그에 노출하지 않는다(건수만). 원클릭 수신거부는 {@link #unsubscribe(String)}.
 */
@Service
public class MarketingService {

    private static final ObjectMapper JSON = new ObjectMapper();

    /** EDM 배치 크기(디스패치 청크). 회당 상한/rate cap 은 MailProperties(설정값)로 강제. */
    private static final int BATCH_SIZE = 50;

    private final MarketingMapper mapper;
    private final com.zioinfo.kintex.mail.MailService mailService;
    private final com.zioinfo.kintex.mail.MailProperties mailProperties;

    public MarketingService(MarketingMapper mapper,
                            com.zioinfo.kintex.mail.MailService mailService,
                            com.zioinfo.kintex.mail.MailProperties mailProperties) {
        this.mapper = mapper;
        this.mailService = mailService;
        this.mailProperties = mailProperties;
    }

    // ── 캠페인 ──
    public List<CampaignDto> campaigns(String eventId, String status) {
        List<Map<String, Object>> rows = mapper.findCampaigns(eventId, blankToNull(status));
        List<CampaignDto> out = new ArrayList<>(rows == null ? 0 : rows.size());
        if (rows != null) {
            for (Map<String, Object> r : rows) {
                out.add(new CampaignDto(
                        str(r.get("id")), str(r.get("name")), str(r.get("status")), intVal(r.get("audience")),
                        str(r.get("meta")), str(r.get("openRate")), str(r.get("clickRate"))));
            }
        }
        return out;
    }

    public CampaignDto createCampaign(String eventId, CampaignCreateRequest req) {
        String name = trimOrNull(req.name());
        if (name == null) {
            throw new ApiException(ErrorCode.VALIDATION, "캠페인 이름을 입력해 주세요.");
        }
        if (mapper.eventExists(eventId) == 0) {
            throw new ApiException(ErrorCode.NOT_FOUND, "존재하지 않는 행사입니다.");
        }
        boolean later = "later".equalsIgnoreCase(str(req.scheduleType()))
                && req.scheduledAt() != null && !req.scheduledAt().isBlank();
        String status = later ? "scheduled" : "draft";
        int audience = req.audience() == null ? 0 : Math.max(req.audience(), 0);
        String meta = later ? "발송 예정: " + req.scheduledAt() : "최종 수정: 방금 전";
        String id = "cp-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16);
        // 빈 문자열로 전달(널 바인딩 회피) — 매퍼가 '' 를 NULL scheduled_at 으로 처리.
        mapper.insertCampaign(id, eventId, name, status, audience, meta,
                later ? req.scheduledAt() : "");
        return new CampaignDto(id, name, status, audience, meta, "-", "-");
    }

    /**
     * 캠페인 발송(수신동의자 한정). 정보통신망법 준수 — 대상: agree_marketing=true + 미수신거부 + 이메일 보유.
     * <p>안전장치:
     * <ol>
     *   <li><b>수신동의 필터</b> — 매퍼가 동의·미거부·이메일 보유만 반환.</li>
     *   <li><b>발송 상한</b> — 캠페인당 상한(edmCampaignCap) + 1회 rate cap(edmRatePerMinute). 초과분은 skipped.</li>
     *   <li><b>테스트 모드(기본 ON)</b> — live≠true 면 실 수신자 미발송. 지정 테스트 수신함으로만 보내거나(없으면) dry-run 집계만.</li>
     *   <li><b>수신거부 링크</b> — 각 본문에 불투명 토큰 원클릭 수신거부 링크 삽입.</li>
     * </ol>
     * 수신자 원문 이메일은 발송 경로 한정 — 응답/로그에는 건수만 노출한다(§0-3).
     *
     * @param live 운영 실발송 여부(true 만 실 수신자 발송). null/false 는 테스트 모드.
     */
    public CampaignSendResult sendCampaign(String eventId, String campaignId, Boolean live) {
        Map<String, Object> c = mapper.findCampaign(eventId, campaignId);
        if (c == null) {
            throw new ApiException(ErrorCode.NOT_FOUND, "존재하지 않는 캠페인입니다.");
        }
        String status = str(c.get("status"));
        if ("sending".equalsIgnoreCase(status) || "done".equalsIgnoreCase(status)) {
            throw new ApiException(ErrorCode.CONFLICT, "이미 발송되었거나 발송 중인 캠페인입니다.");
        }
        String name = str(c.get("name"));

        // ── 발송 상한: 캠페인당 상한으로 대상 조회, rate cap 으로 1회 디스패치 제한 ──
        int cap = Math.max(mailProperties.getEdmCampaignCap(), 0);
        int rate = Math.max(mailProperties.getEdmRatePerMinute(), 0);
        long eligibleTotal = mapper.countMarketingRecipients(eventId);
        List<Map<String, Object>> recipients = mapper.listMarketingRecipients(eventId, cap);
        if (recipients == null) recipients = List.of();

        boolean mailEnabled = mailProperties.isEnabled();
        // 테스트 모드 판정: 운영 발송은 live=true 명시 필수(기본 안전).
        boolean testMode = !(Boolean.TRUE.equals(live)) || mailProperties.isEdmTestMode();
        // 운영 발송을 명시해도 기본 설정이 테스트 모드이면 테스트 유지(2중 안전 — env 로만 운영 개방).
        if (Boolean.TRUE.equals(live) && !mailProperties.isEdmTestMode()) {
            testMode = false;
        }

        String subject = "[KINTEX] " + (name == null ? "행사 소식" : name);
        String baseUrl = trimTrailingSlash(mailProperties.getWebBaseUrl());

        int eligible = recipients.size();
        int overCap = (int) Math.max(eligibleTotal - eligible, 0);   // 캠페인 상한 초과 스킵
        int sentOk = 0;
        int skipped = overCap;
        int failed = 0;

        if (testMode) {
            // 테스트 모드 — 실 수신자 미발송. 지정 테스트 수신함으로만 발송(없으면 dry-run 카운트만).
            List<String> testTo = sanitize(mailProperties.getEdmTestRecipients());
            String html = com.zioinfo.kintex.mail.MailTemplates.edm(name, "KINTEX",
                    baseUrl + "/api/public/marketing/unsubscribe?token=SAMPLE");
            for (String to : testTo) {
                mailService.send("EDM_TEST", campaignId, to, "[TEST] " + subject, html);
                sentOk++;
            }
            skipped += eligible;   // 실 대상 전원 이번엔 미발송(테스트)
            // sentOk 는 큐 적재(시도) 건수 — 실제 SMTP 성공/실패는 비동기라 mail_log 가 정본(2026-07-14 인증실패 사례).
            String meta = testTo.isEmpty()
                    ? "테스트(dry-run): 대상 " + eligible + "명 · 실발송 없음"
                    : "테스트 발송 요청: 테스트 수신함 " + sentOk + "건 큐 적재(결과는 발송 이력 참조) · 실대상 " + eligible + "명 미발송";
            // 상태 미전이(draft/scheduled 유지) — 테스트는 캠페인을 소진하지 않는다.
            mapper.markCampaignResult(eventId, campaignId, status, sentOk, skipped, failed, true, meta);
            return new CampaignSendResult(campaignId, status, true, mailEnabled,
                    eligible, sentOk, skipped, failed, meta);
        }

        // ── 운영 실발송 — rate cap 만큼만 이번 호출에서 디스패치, 초과는 rate_capped 스킵 ──
        int dispatchLimit = rate > 0 ? Math.min(eligible, rate) : eligible;
        List<String[]> toDispatch = new ArrayList<>(dispatchLimit);  // [email, unsubToken]
        for (int i = 0; i < recipients.size(); i++) {
            Map<String, Object> r = recipients.get(i);
            String email = str(r.get("email"));
            String token = str(r.get("unsubToken"));
            if (email == null || email.isBlank()) {
                failed++;   // 방어(매퍼 필터로 사실상 발생 안 함)
                continue;
            }
            if (i < dispatchLimit) {
                toDispatch.add(new String[]{email, token});
            } else {
                skipped++;  // rate cap 초과 — 후속 호출에서 재발송 가능
            }
        }

        for (List<String[]> batch : com.zioinfo.kintex.mail.MailBatch.partition(toDispatch, BATCH_SIZE)) {
            for (String[] pair : batch) {
                String unsubUrl = baseUrl + "/api/public/marketing/unsubscribe?token="
                        + urlEncode(pair[1] == null ? "" : pair[1]);
                String html = com.zioinfo.kintex.mail.MailTemplates.edm(name, "KINTEX", unsubUrl);
                mailService.send("EDM", campaignId, pair[0], subject, html);
                sentOk++;
            }
        }

        // 이번 호출로 대상 전부 소진했으면 done, 아니면 sending(잔여는 재호출 발송).
        int remaining = skipped - overCap - failed;   // rate cap 잔여
        String newStatus = remaining > 0 ? "sending" : "done";
        String meta = mailEnabled
                ? "발송 요청 " + sentOk + "건 큐 적재 (대상 " + eligibleTotal + "명"
                    + (remaining > 0 ? " · 잔여 " + remaining + "명" : "")
                    + (overCap > 0 ? " · 상한초과 " + overCap + "명" : "") + " · 결과는 발송 이력 참조)"
                : "발송 대상 " + sentOk + "건 (MAIL 비활성 — 실 전송 없음)";
        mapper.markCampaignResult(eventId, campaignId, newStatus, sentOk, skipped, failed, false, meta);
        return new CampaignSendResult(campaignId, newStatus, false, mailEnabled,
                (int) Math.min(eligibleTotal, Integer.MAX_VALUE), sentOk, skipped, failed, meta);
    }

    /**
     * 원클릭 수신거부 처리(공개·비인증). 불투명 토큰으로 대상 등록의 수신거부 플래그를 멱등 설정한다.
     * 토큰이 존재하면(이미 처리된 건 포함) 성공 안내한다. 잘못된 토큰은 실패로 구분.
     *
     * @return true=처리/이미처리(토큰 유효), false=토큰 불일치.
     */
    public boolean unsubscribe(String token) {
        if (token == null || token.isBlank()) {
            return false;
        }
        String t = token.trim();
        int updated = mapper.markUnsubscribed(t);
        if (updated > 0) {
            return true;
        }
        // 이미 거부되었거나(멱등) 토큰 존재 여부로 성공/실패 구분.
        return mapper.unsubTokenExists(t) > 0;
    }

    // ── 스폰서십 ──
    public SponsorshipView sponsorship(String eventId) {
        List<Map<String, Object>> pkgRows = mapper.findPackages(eventId);
        List<Map<String, Object>> sponsorRows = mapper.findSponsors(eventId);
        Map<String, Object> counts = mapper.findSponsorshipCounts(eventId);
        if (counts == null) counts = Map.of();

        List<TierDto> tiers = new ArrayList<>();
        long soldValue = 0;
        if (pkgRows != null) {
            for (Map<String, Object> p : pkgRows) {
                long total = lng(p.get("totalQty"));
                long sold = lng(p.get("soldQty"));
                long price = lng(p.get("price"));
                int remaining = (int) Math.max(total - sold, 0);
                soldValue += price * sold;
                tiers.add(new TierDto(
                        str(p.get("id")), str(p.get("name")), str(p.get("nameKo")),
                        formatWon(price), str(p.get("accent")), parseStrings(p.get("benefits")),
                        remaining, remaining <= 0 ? "soldout" : "available",
                        p.get("dday") == null ? null : intVal(p.get("dday"))));
            }
        }

        List<SponsorDto> sponsors = new ArrayList<>();
        long fulfillTotal = 0, fulfillDone = 0;
        if (sponsorRows != null) {
            for (Map<String, Object> s : sponsorRows) {
                List<Fulfillment> f = parseFulfillment(s.get("fulfillment"));
                for (Fulfillment item : f) {
                    fulfillTotal++;
                    if (item.done()) fulfillDone++;
                }
                String name = str(s.get("name"));
                sponsors.add(new SponsorDto(
                        str(s.get("id")), name,
                        (name == null || name.isEmpty()) ? "?" : name.substring(0, 1),
                        str(s.get("tierLabel")), str(s.get("contractStatus")), f));
            }
        }

        List<MktKpi> kpis = new ArrayList<>();
        kpis.add(new MktKpi("스폰서", String.valueOf(lng(counts.get("sponsorCount"))), "사", "primary"));
        kpis.add(new MktKpi("판매액", formatMillion(soldValue), null, "primary"));
        kpis.add(new MktKpi("잔여 패키지", String.valueOf(lng(counts.get("remainingPackages"))), "개", "ai"));
        kpis.add(new MktKpi("이행물 진행", fulfillTotal > 0 ? String.valueOf(Math.round(fulfillDone * 100.0 / fulfillTotal)) : "0",
                "%", "success"));

        return new SponsorshipView(kpis, tiers, sponsors);
    }

    // ── helpers ──
    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<Fulfillment> parseFulfillment(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<Fulfillment> out = new ArrayList<>(raw.size());
            for (Map<String, Object> m : raw) {
                out.add(new Fulfillment(str(m.get("id")), str(m.get("label")),
                        m.get("done") instanceof Boolean b ? b : Boolean.parseBoolean(str(m.get("done")))));
            }
            return out;
        } catch (Exception e) {
            return List.of();
        }
    }

    private static String formatWon(long won) {
        return "₩" + String.format("%,d", won);
    }

    private static String formatMillion(long won) {
        double m = won / 1_000_000.0;
        return "₩" + String.format("%.1f", m) + "M";
    }

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

    private static String trimTrailingSlash(String s) {
        if (s == null || s.isBlank()) return "";
        String t = s.trim();
        return t.endsWith("/") ? t.substring(0, t.length() - 1) : t;
    }

    /** 콤마/공백 정리 — 빈 값 제거, 트림. */
    private static List<String> sanitize(List<String> in) {
        List<String> out = new ArrayList<>();
        if (in != null) {
            for (String s : in) {
                if (s != null && !s.isBlank()) {
                    out.add(s.trim());
                }
            }
        }
        return out;
    }

    private static String urlEncode(String s) {
        return java.net.URLEncoder.encode(s, java.nio.charset.StandardCharsets.UTF_8);
    }

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

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

    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);
    }
}
