package com.zioinfo.kintex.cms;

import com.zioinfo.kintex.ai.AiTextRouter;
import com.zioinfo.kintex.ai.AiTextRouter.AiResult;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.cms.dto.*;
import com.zioinfo.kintex.common.PageResponse;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.common.text.HtmlSanitizer;
import com.zioinfo.kintex.system.SystemAccessGuard;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.*;

/**
 * M17 CMS 서비스 — 콘텐츠 게시 워크플로 + 버전 이력/롤백 + 미디어 + 다국어 + 예약 게시 + 공개 조회.
 * <p>상태 전이: draft(0)→review(1)→approved(2)→published(3) 전진만 허용. 역전이/동일 전이 → 400(VALIDATION).
 * approved·published 전이와 롤백은 관리자/주최자(매니저 이상) 권한 필수(kintex-admin-dev RBAC 정합).
 * <p>본문 저장 시 {@link HtmlSanitizer}로 XSS 방어(N2). AI 자동 번역은 인터페이스만(501, AiTextRouter 배선 대기).
 */
@Service
public class CmsService {

    private static final Logger log = LoggerFactory.getLogger(CmsService.class);

    private static final List<String> FLOW = List.of("draft", "review", "approved", "published");
    private static final Set<String> TRANS_STATUS = Set.of("none", "ai", "reviewed");
    private static final Set<String> LANGS = Set.of("ko", "en", "zh", "ja");

    private static final long MAX_MEDIA_BYTES = 8L * 1024 * 1024; // 8MB
    private static final Map<String, String> ALLOWED_TYPES = Map.of(
            "image/png", "png", "image/jpeg", "jpg", "image/webp", "webp", "image/gif", "gif");
    private static final Set<String> ALLOWED_EXTS = Set.of("png", "jpg", "jpeg", "webp", "gif");

    private static final Map<String, String> LANG_NAMES = Map.of(
            "ko", "한국어(Korean)", "en", "영어(English)", "zh", "중국어 간체(Simplified Chinese)", "ja", "일본어(Japanese)");

    private final CmsMapper mapper;
    private final SystemAccessGuard scope;
    private final AiTextRouter aiRouter;
    private final Path uploadRoot;

    public CmsService(CmsMapper mapper, SystemAccessGuard scope, AiTextRouter aiRouter,
                      @Value("${kintex.upload.dir:./data/uploads}") String uploadDir) {
        this.mapper = mapper;
        this.scope = scope;
        this.aiRouter = aiRouter;
        this.uploadRoot = Paths.get(uploadDir).toAbsolutePath().normalize();
    }

    // ── 콘텐츠 목록/조회 ───────────────────────────────────────────────────────
    public PageResponse<CmsContentDto> list(String eventId, String status, String type, String keyword,
                                            int page, int size) {
        runScheduledPublish();
        int p = Math.max(page, 0);
        int s = size <= 0 ? 50 : Math.min(size, 200);
        Map<String, Object> q = new HashMap<>();
        q.put("eventId", blankToNull(eventId));
        q.put("status", blankToNull(status));
        q.put("type", blankToNull(type));
        q.put("keyword", blankToNull(keyword));
        q.put("size", s);
        q.put("offset", p * s);
        List<Map<String, Object>> rows = mapper.findContents(q);
        long total = mapper.countContents(q);
        return PageResponse.of(rows.stream().map(CmsService::toContentDto).toList(), p, s, total);
    }

    public CmsContentDto get(String id) {
        Map<String, Object> r = mapper.findContentById(id);
        if (r == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        return toContentDto(r);
    }

    @Transactional
    public CmsContentDto create(KintexPrincipal principal, CmsContentCreateRequest req) {
        String id = "cms-" + UUID.randomUUID().toString().substring(0, 12);
        Map<String, Object> pm = new HashMap<>();
        pm.put("id", id);
        pm.put("eventId", blankToNull(req.eventId()));
        pm.put("contentType", blankToNull(req.contentType()));
        pm.put("title", req.title());
        pm.put("body", HtmlSanitizer.sanitize(req.body()));
        pm.put("lang", blankToNull(req.lang()));
        pm.put("authorId", principal.userId());
        pm.put("authorName", principal.displayName());
        mapper.insertContent(pm);
        snapshot(id, "created", principal.displayName());
        return get(id);
    }

    /** 본문/제목/예약 저장(SCR-35 에디터). sanitize + 저장 후 버전 스냅샷. */
    @Transactional
    public CmsContentDto update(KintexPrincipal principal, String id, CmsContentUpdateRequest req) {
        if (mapper.findContentById(id) == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        if (req.title() == null || req.title().isBlank()) {
            throw new ApiException(ErrorCode.VALIDATION, "제목을 입력해 주세요.");
        }
        Map<String, Object> pm = new HashMap<>();
        pm.put("id", id);
        pm.put("title", req.title().trim());
        pm.put("body", HtmlSanitizer.sanitize(req.body()));
        pm.put("scheduledAt", blankToNull(req.scheduledAt()));
        pm.put("signage", req.signage());
        pm.put("mailing", req.mailing());
        mapper.updateContent(pm);
        snapshot(id, "edited", principal.displayName());
        return get(id);
    }

    /** 상태 전이 — value 는 목표 상태. 전진만 허용, 역전이/무효 값 → 400. */
    @Transactional
    public CmsContentDto transition(KintexPrincipal principal, String id, String value) {
        Map<String, Object> r = mapper.findContentById(id);
        if (r == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        String from = String.valueOf(r.get("status"));
        String to = value == null ? "" : value.trim();
        int fromIdx = FLOW.indexOf(from);
        int toIdx = FLOW.indexOf(to);
        if (toIdx < 0) {
            throw new ApiException(ErrorCode.VALIDATION, "알 수 없는 게시 상태입니다: " + to);
        }
        if (toIdx <= fromIdx) {
            throw new ApiException(ErrorCode.VALIDATION,
                    "게시 워크플로는 전진만 가능합니다(" + from + " → " + to + " 불가).");
        }
        // 승인·게시는 관리자/주최자(매니저 이상) 권한
        if ("approved".equals(to) || "published".equals(to)) {
            scope.requireManager(principal);
        }
        mapper.updateStatus(id, to);
        if ("published".equals(to)) {
            snapshot(id, "published", principal.displayName());
        }
        return get(id);
    }

    // ── 버전 이력/롤백 ─────────────────────────────────────────────────────────
    public List<CmsContentVersionDto> versions(String contentId) {
        if (mapper.findContentById(contentId) == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        return mapper.findVersions(contentId).stream().map(CmsService::toVersionDto).toList();
    }

    /** 특정 버전으로 롤백(매니저 이상). 복원 후 롤백 스냅샷을 새 버전으로 남긴다. */
    @Transactional
    public CmsContentDto rollback(KintexPrincipal principal, String contentId, int versionNo) {
        scope.requireManager(principal);
        if (mapper.findContentById(contentId) == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        Map<String, Object> v = mapper.findVersion(contentId, versionNo);
        if (v == null) {
            throw new ApiException(ErrorCode.NOT_FOUND, "해당 버전을 찾을 수 없습니다: v" + versionNo);
        }
        Map<String, Object> pm = new HashMap<>();
        pm.put("id", contentId);
        pm.put("title", str(v.get("title")));
        pm.put("body", str(v.get("body")));
        pm.put("status", str(v.get("status")));
        mapper.restoreContent(pm);
        snapshot(contentId, "rollback-from-v" + versionNo, principal.displayName());
        return get(contentId);
    }

    /** 현재 콘텐츠 상태를 버전 스냅샷으로 보존(단조 증가). */
    private void snapshot(String contentId, String note, String authorName) {
        Map<String, Object> cur = mapper.findContentById(contentId);
        if (cur == null) {
            return;
        }
        int next = mapper.maxVersionNo(contentId) + 1;
        Map<String, Object> vp = new HashMap<>();
        vp.put("id", "cv-" + UUID.randomUUID().toString().substring(0, 12));
        vp.put("contentId", contentId);
        vp.put("versionNo", next);
        vp.put("title", str(cur.get("title")));
        vp.put("body", str(cur.get("body")));
        vp.put("status", str(cur.get("status")));
        vp.put("authorName", authorName);
        vp.put("note", note);
        mapper.insertVersion(vp);
    }

    // ── 번역 ─────────────────────────────────────────────────────────────────
    public List<CmsTranslationDto> translations(String contentId) {
        if (mapper.findContentById(contentId) == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        return mapper.findTranslations(contentId).stream().map(CmsService::toTranslationDto).toList();
    }

    @Transactional
    public List<CmsTranslationDto> saveTranslation(String contentId, CmsTranslationSaveRequest req) {
        if (mapper.findContentById(contentId) == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        String lang = req.lang() == null ? "" : req.lang().trim();
        if (!LANGS.contains(lang)) {
            throw new ApiException(ErrorCode.VALIDATION, "지원하지 않는 언어입니다: " + lang);
        }
        String st = req.transStatus() == null ? "none" : req.transStatus().trim();
        if (!TRANS_STATUS.contains(st)) {
            throw new ApiException(ErrorCode.VALIDATION, "알 수 없는 번역 상태입니다: " + st);
        }
        Map<String, Object> pm = new HashMap<>();
        pm.put("id", "tr-" + UUID.randomUUID().toString().substring(0, 12));
        pm.put("contentId", contentId);
        pm.put("lang", lang);
        pm.put("title", req.title());
        pm.put("body", HtmlSanitizer.sanitize(req.body()));
        pm.put("transStatus", st);
        mapper.upsertTranslation(pm);
        return translations(contentId);
    }

    /**
     * AI 자동 번역(초벌) — {@link AiTextRouter}로 원문(KO)→대상언어 번역 후 trans_status='ai' 로 upsert(F100/F069).
     * 매니저 이상(주최자·관리자) 권한. AI 산출물이므로 프론트는 AiLabel 로 표기하고 사람 검수를 거친다.
     *
     * <p><b>보안</b>: 본문은 {@link HtmlSanitizer}로 sanitize 후 저장(XSS N2). <b>폴백</b>: AI 미가용/실패/파싱 실패 시
     * 규칙 대체가 불가능한 작업이므로 {@code AI_UNAVAILABLE}(503)로 정직하게 반환한다(환각 없이 저장하지 않음).
     */
    @Transactional
    public CmsTranslationDto aiTranslate(KintexPrincipal principal, String contentId, String lang) {
        scope.requireManager(principal);
        Map<String, Object> content = mapper.findContentById(contentId);
        if (content == null) {
            throw new ApiException(ErrorCode.NOT_FOUND);
        }
        String target = lang == null ? "" : lang.trim();
        if (!LANGS.contains(target) || "ko".equals(target)) {
            throw new ApiException(ErrorCode.VALIDATION, "지원하지 않는 대상 언어입니다: " + lang);
        }
        String srcTitle = str(content.get("title"));
        String srcBody = str(content.get("body"));

        AiResult ai = aiRouter.generate(buildTranslatePrompt(srcTitle, srcBody, target), 2048);
        if (!ai.usable()) {
            throw new ApiException(ErrorCode.AI_UNAVAILABLE);
        }
        String[] parsed = parseTranslation(ai.text());
        if (parsed == null) {
            throw new ApiException(ErrorCode.AI_UNAVAILABLE);
        }
        Map<String, Object> pm = new HashMap<>();
        pm.put("id", "tr-" + UUID.randomUUID().toString().substring(0, 12));
        pm.put("contentId", contentId);
        pm.put("lang", target);
        pm.put("title", parsed[0]);
        pm.put("body", HtmlSanitizer.sanitize(parsed[1]));
        pm.put("transStatus", "ai");
        mapper.upsertTranslation(pm);
        return translations(contentId).stream()
                .filter(t -> target.equals(t.lang()))
                .findFirst()
                .orElseThrow(() -> new ApiException(ErrorCode.INTERNAL));
    }

    /** 번역 프롬프트 — JSON 전용 출력(제목·본문). HTML 보존·의미 왜곡 금지 지시(환각 억제). */
    private static String buildTranslatePrompt(String title, String body, String targetLang) {
        String langName = LANG_NAMES.getOrDefault(targetLang, targetLang);
        StringBuilder sb = new StringBuilder();
        sb.append("당신은 전시회 홍보 콘텐츠 전문 번역가다. 아래 한국어 콘텐츠를 ").append(langName)
                .append("로 자연스럽게 번역하라.\n");
        sb.append("규칙: 의미를 왜곡하거나 없는 내용을 추가하지 마라. HTML 태그·구조는 그대로 보존하고 태그 안 텍스트만 번역하라.\n");
        sb.append("오직 아래 JSON 형식으로만 답하라(설명·마크다운·코드펜스 금지):\n");
        sb.append("{\"title\": \"<번역된 제목>\", \"body\": \"<번역된 본문(HTML 보존)>\"}\n\n");
        sb.append("[원문 제목]\n").append(title == null ? "" : title).append("\n\n");
        sb.append("[원문 본문]\n").append(body == null ? "" : body).append('\n');
        return sb.toString();
    }

    /** AI 응답(JSON)에서 [title, body] 추출. 실패 시 null. */
    private static String[] parseTranslation(String text) {
        if (text == null || text.isBlank()) {
            return null;
        }
        String json = extractJsonObject(text);
        if (json == null) {
            return null;
        }
        try {
            com.fasterxml.jackson.databind.JsonNode root =
                    new com.fasterxml.jackson.databind.ObjectMapper().readTree(json);
            com.fasterxml.jackson.databind.JsonNode t = root.get("title");
            com.fasterxml.jackson.databind.JsonNode b = root.get("body");
            String title = t == null || t.isNull() ? "" : t.asText("");
            String body = b == null || b.isNull() ? "" : b.asText("");
            if (title.isBlank() && body.isBlank()) {
                return null;
            }
            return new String[]{title, body};
        } 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;
    }

    // ── 미디어 라이브러리 ──────────────────────────────────────────────────────
    public List<CmsMediaDto> mediaList(String eventId, int limit) {
        Map<String, Object> q = new HashMap<>();
        q.put("eventId", blankToNull(eventId));
        q.put("limit", limit <= 0 ? 60 : Math.min(limit, 200));
        return mapper.findMedia(q).stream().map(CmsService::toMediaDto).toList();
    }

    @Transactional
    public CmsMediaDto addMedia(KintexPrincipal principal, MultipartFile file, String eventId, String altText) {
        if (file == null || file.isEmpty()) {
            throw new ApiException(ErrorCode.VALIDATION, "업로드할 이미지를 첨부해 주세요.");
        }
        if (file.getSize() > MAX_MEDIA_BYTES) {
            throw new ApiException(ErrorCode.VALIDATION, "이미지는 8MB 이하만 업로드할 수 있습니다.");
        }
        String ext = resolveExtension(file);
        String fileName = UUID.randomUUID() + "." + ext;
        try {
            Path dir = uploadRoot.resolve("cms");
            Files.createDirectories(dir);
            Path target = dir.resolve(fileName).normalize();
            if (!target.startsWith(uploadRoot)) {
                throw new ApiException(ErrorCode.VALIDATION, "잘못된 파일 경로입니다.");
            }
            try (var in = file.getInputStream()) {
                Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
            }
        } catch (IOException e) {
            log.error("cms media store failed: {}", e.getMessage());
            throw new ApiException(ErrorCode.INTERNAL, "이미지 저장에 실패했습니다.");
        }
        String id = "cm-" + UUID.randomUUID().toString().substring(0, 12);
        String display = safeDisplayName(file.getOriginalFilename(), ext);
        Map<String, Object> pm = new HashMap<>();
        pm.put("id", id);
        pm.put("eventId", blankToNull(eventId));
        pm.put("fileName", display);
        pm.put("url", "/uploads/cms/" + fileName);
        pm.put("contentType", file.getContentType());
        pm.put("sizeBytes", file.getSize());
        pm.put("altText", blankToNull(altText));
        pm.put("uploaderName", principal.displayName());
        mapper.insertMedia(pm);
        return mediaList(eventId, 200).stream().filter(m -> m.id().equals(id)).findFirst()
                .orElseThrow(() -> new ApiException(ErrorCode.INTERNAL));
    }

    // ── 예약 게시 / 공개 조회 ──────────────────────────────────────────────────
    /** 예약 시각 도달분 자동 게시(조회 시 지연 처리). 반환=이번에 게시된 건수. */
    @Transactional
    public int runScheduledPublish() {
        try {
            return mapper.publishDueScheduled();
        } catch (RuntimeException e) {
            log.warn("scheduled publish sweep skipped: {}", e.getMessage());
            return 0;
        }
    }

    /** 공개 조회(무인증) — 게시 상태만. 조회 시 예약 도달분 자동 게시 후 반환. */
    public List<CmsContentDto> publicByType(String type, String eventId, int limit) {
        runScheduledPublish();
        Map<String, Object> q = new HashMap<>();
        q.put("type", type == null ? "PAGE" : type.trim().toUpperCase(Locale.ROOT));
        q.put("eventId", blankToNull(eventId));
        q.put("limit", limit <= 0 ? 50 : Math.min(limit, 200));
        return mapper.findPublishedByType(q).stream().map(CmsService::toPublicDto).toList();
    }

    // ── 매핑 ─────────────────────────────────────────────────────────────────
    private static CmsContentDto toContentDto(Map<String, Object> r) {
        return new CmsContentDto(
                str(r.get("id")), str(r.get("eventId")), str(r.get("contentType")),
                str(r.get("title")), str(r.get("body")), str(r.get("status")), str(r.get("lang")),
                str(r.get("scheduledAt")), bool(r.get("signage")), bool(r.get("mailing")),
                str(r.get("authorName")), str(r.get("publishedAt")),
                str(r.get("createdAt")), str(r.get("updatedAt")));
    }

    /** 공개 응답 — 저자/예약/플래그 등 운영 필드는 노출하지 않는다. */
    private static CmsContentDto toPublicDto(Map<String, Object> r) {
        return new CmsContentDto(
                str(r.get("id")), str(r.get("eventId")), str(r.get("contentType")),
                str(r.get("title")), str(r.get("body")), str(r.get("status")), str(r.get("lang")),
                null, false, false, null, str(r.get("publishedAt")),
                null, str(r.get("updatedAt")));
    }

    private static CmsTranslationDto toTranslationDto(Map<String, Object> r) {
        return new CmsTranslationDto(
                str(r.get("contentId")), str(r.get("lang")), str(r.get("title")),
                str(r.get("body")), str(r.get("transStatus")), str(r.get("updatedAt")));
    }

    private static CmsContentVersionDto toVersionDto(Map<String, Object> r) {
        return new CmsContentVersionDto(
                str(r.get("id")), str(r.get("contentId")),
                ((Number) r.get("versionNo")).intValue(),
                str(r.get("title")), str(r.get("body")), str(r.get("status")),
                str(r.get("authorName")), str(r.get("note")), str(r.get("createdAt")));
    }

    private static CmsMediaDto toMediaDto(Map<String, Object> r) {
        return new CmsMediaDto(
                str(r.get("id")), str(r.get("eventId")), str(r.get("fileName")), str(r.get("url")),
                str(r.get("contentType")),
                r.get("sizeBytes") == null ? null : ((Number) r.get("sizeBytes")).longValue(),
                str(r.get("altText")), str(r.get("uploaderName")), str(r.get("createdAt")));
    }

    private static String resolveExtension(MultipartFile file) {
        String byType = file.getContentType() == null
                ? null : ALLOWED_TYPES.get(file.getContentType().toLowerCase(Locale.ROOT));
        if (byType != null) {
            return byType;
        }
        String name = file.getOriginalFilename();
        if (name != null && name.contains(".")) {
            String ext = name.substring(name.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
            if (ALLOWED_EXTS.contains(ext)) {
                return "jpeg".equals(ext) ? "jpg" : ext;
            }
        }
        throw new ApiException(ErrorCode.VALIDATION, "PNG/JPG/WebP/GIF 이미지만 업로드할 수 있습니다.");
    }

    /** 표시용 파일명 — 원본은 신뢰하지 않고 경로 구분자/제어문자 제거 후 길이 제한. */
    private static String safeDisplayName(String original, String ext) {
        if (original == null || original.isBlank()) {
            return "image." + ext;
        }
        String base = original.replaceAll("[\\\\/\\x00-\\x1f]", "_").trim();
        return base.length() > 120 ? base.substring(0, 120) : base;
    }

    private static String blankToNull(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) {
        return o instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(o));
    }
}
