package com.gzzm.lobster.preview;

import com.gzzm.lobster.common.LobsterException;
import com.gzzm.lobster.config.LobsterConfig;
import com.gzzm.platform.commons.Tools;
import com.spire.presentation.Presentation;
import com.spire.xls.FileFormat;
import com.spire.xls.Workbook;
import net.cyan.arachne.annotation.Service;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;

/**
 * OfficePreviewService —— Office 文件 → HTML 预览转换服务.
 *
 * <p>底层用 Spire（已用于 zm-rag/com.gzzm.filepreview.FilePreview）；支持 docx/doc/xlsx/xls/pptx/ppt/ofd.
 * PDF 不在这里转——浏览器原生 PDF viewer 直接 iframe 后端 raw 端点就行，绕一圈反而失真.
 *
 * <p>缓存策略：以 caller 给的 cacheKey 作为文件名（caller 负责把 artifactId+version /
 * resourceId 之类的不变量编进去），存到 {@link LobsterConfig#getContentStoreBase()} 下的
 * {@code office-preview/} 子目录. 命中即直接 return File，不重转.
 *
 * <p>Spire 的转换是单线程不安全 + 慢（首次几百毫秒到几秒），上层别同步阻塞主请求线程跑大文件，
 * 必要时套异步队列；目前所有 caller 都是同步预览（用户点了才触发），首次慢一次后续秒开.
 */
@Service
public class OfficePreviewService {

    private static final String CACHE_SUBDIR = "office-preview";
    private static final AtomicBoolean SPIRE_FONTS_CONFIGURED = new AtomicBoolean(false);
    private static volatile String spireFontDir;

    /**
     * 受支持扩展名（小写、不带点）. 调度方据此判断要不要走 office-html 路径，
     * 不在白名单的格式直接返 false 让上层 fallback 到 raw 下载.
     */
    private static final Set<String> SUPPORTED = new HashSet<>(Arrays.asList(
            "docx", "doc", "wps",
            "xlsx", "xls",
            "pptx", "ppt",
            "ofd"
    ));

    public static boolean supports(String ext) {
        return ext != null && SUPPORTED.contains(ext.toLowerCase(Locale.ROOT));
    }

    /**
     * 输出格式——决定缓存文件后缀和 caller 给浏览器的 Content-Type.
     *
     * <p>策略：
     * <ul>
     *   <li>docx/doc/wps → pdf. Word 转 PDF 单文件、版式稳，浏览器原生 viewer 渲染. 之前转 HTML
     *       虽然 single-file 可行，但 docx 里复杂表 / 公式 / 页眉页脚转 HTML 容易跑偏，PDF 保真度高.</li>
     *   <li>pptx/ppt → pdf. PPT 转 HTML 是每页一个文件 + frameset 索引，复杂度高；转 PDF 一页对一页，自带缩略图导航.</li>
     *   <li>xlsx/xls → html. Spire excel 转 HTML 是 frameset + _files 子目录（多 sheet 拆开），
     *       配合 office-asset 端点已经走通；多 sheet 切换体验比 PDF 顺，保留.</li>
     *   <li>ofd → pdf. OfdConverter 直接输出 PDF.</li>
     * </ul>
     */
    public static String outputExt(String ext) {
        if (ext == null) return "html";
        switch (ext.toLowerCase(Locale.ROOT)) {
            case "docx":
            case "doc":
            case "wps":
            case "pptx":
            case "ppt":
            case "ofd":
                return "pdf";
            default:  // xlsx / xls 走 html
                return "html";
        }
    }

    /** 输出是否 PDF——caller 用来选 mime（application/pdf vs text/html）和 fileName 后缀. */
    public boolean outputIsPdf(String ext) {
        return "pdf".equals(outputExt(ext));
    }

    /**
     * 探缓存——已转换过的输出文件存在且非空就返回它，否则返 null.
     *
     * <p>caller 在调 {@link #renderHtml} 之前先用这个探一下，命中可以跳过 readBytes
     * （OA 上几 MB 大文件白读一次磁盘 + 装内存挺浪费）；不命中再走 renderHtml 全套流程.
     *
     * <p>路径穿越防护与 {@link #renderHtml} 同套——非法 cacheKey 静默返 null（不抛），
     * 让 caller 自然 fallback 到 renderHtml 那里再统一报错.
     */
    public File cachedFile(String cacheKey, String ext) {
        if (cacheKey == null || cacheKey.isEmpty()) return null;
        if (cacheKey.indexOf('/') >= 0 || cacheKey.indexOf('\\') >= 0 || cacheKey.contains("..")) return null;
        String suffix = outputExt(ext);
        Path output = resolveCacheDir().resolve(cacheKey + "." + suffix);
        File f = output.toFile();
        if (f.exists() && f.length() > 0) {
            try { Tools.log("[OfficePreview] cache hit " + cacheKey + "." + suffix); } catch (Throwable ignore) { /* ignore */ }
            return f;
        }
        return null;
    }

    /**
     * 探 Spire 转换时生成的辅助文件（_files/ 子目录）.
     *
     * <p>Spire 转 xlsx → HTML 实际产出是 frameset + 同名 {@code _files/} 子目录（sheet 拆成多个 HTML + tab nav），
     * pptx 同理（每页一个 HTML）.主端点只返 frameset 顶层，子文件由这个方法配合 office-asset URL 路由读出.
     *
     * <p>路径穿越防护：filesDir 必须形如 {@code <cacheKey>_files}（不含 / \ ..），fileName 同样限制；
     * 实际 resolve 后再校验落在 cacheDir 内（避免 unicode normalize 之类的绕过）.
     *
     * @return 文件存在且 isFile 才返；不存在 / 越界 / 是目录都返 null 让 caller 走 404 分支
     */
    public File cachedAsset(String filesDir, String fileName) {
        if (filesDir == null || filesDir.isEmpty() || fileName == null || fileName.isEmpty()) return null;
        // 任何一段含路径分隔符或 .. 直接拒——asset 必须严格落在 cacheDir/<filesDir>/ 下一层
        if (filesDir.indexOf('/') >= 0 || filesDir.indexOf('\\') >= 0 || filesDir.contains("..")) return null;
        if (fileName.indexOf('/') >= 0 || fileName.indexOf('\\') >= 0 || fileName.contains("..")) return null;
        if (!filesDir.endsWith("_files")) return null;  // 多一层硬约束：必须是 Spire 生成的命名

        Path cacheDir = resolveCacheDir();
        Path target = cacheDir.resolve(filesDir).resolve(fileName).toAbsolutePath().normalize();
        // 防 unicode normalize / 软链接逃逸；resolve 完再 startsWith 兜底
        if (!target.startsWith(cacheDir)) return null;

        File f = target.toFile();
        return (f.exists() && f.isFile()) ? f : null;
    }

    /**
     * 渲染并返回 HTML 文件. 已缓存就直接返回；没缓存就用 Spire 转一次再返.
     *
     * @param cacheKey 不含路径的文件名前缀，例如 {@code "art_xxx-v3"}；调用方负责保证唯一 + 不变.
     *                 路径穿越防护：拒绝包含 {@code /} {@code \} {@code ..} 的 cacheKey.
     * @param ext      原始扩展名（docx/xlsx/...）.
     * @param bytes    原始文件字节.
     * @return 转换后 HTML 文件的本地路径，可直接以 {@code DownloadFile} 包装返给浏览器.
     */
    public File renderHtml(String cacheKey, String ext, byte[] bytes) {
        if (cacheKey == null || cacheKey.isEmpty()) {
            throw new LobsterException("preview.invalid", "cacheKey required");
        }
        if (cacheKey.indexOf('/') >= 0 || cacheKey.indexOf('\\') >= 0 || cacheKey.contains("..")) {
            throw new LobsterException("preview.invalid", "illegal cacheKey: " + cacheKey);
        }
        String e = ext == null ? "" : ext.toLowerCase(Locale.ROOT);
        if (!supports(e)) {
            throw new LobsterException("preview.unsupported", "Office preview not supported for ext: " + ext);
        }
        if (bytes == null || bytes.length == 0) {
            throw new LobsterException("preview.empty", "empty file bytes for cacheKey=" + cacheKey);
        }

        Path cacheDir = resolveCacheDir();
        Path output = cacheDir.resolve(cacheKey + "." + outputExt(e));
        File outFile = output.toFile();

        // 命中缓存：直接返（caller 应已用 cachedFile 探过；这里再守一次防 race / 调用方忘了）
        if (outFile.exists() && outFile.length() > 0) {
            return outFile;
        }

        long t0 = System.currentTimeMillis();
        try { Tools.log("[OfficePreview] cache miss " + cacheKey + " ext=" + e + " size=" + bytes.length); } catch (Throwable ignore) { /* ignore */ }

        // 各家 Spire SDK 直接 saveToFile(path, FORMAT) 转换；word / slide / ofd 都走 PDF，
        // 唯独 xlsx / xls 还是 HTML（保留 frameset + tabs.html 的多 sheet 切换体验）.
        try {
            switch (e) {
                case "xlsx":
                case "xls":
                    convertExcel(bytes, output);
                    break;
                case "docx":
                case "doc":
                case "wps":
                    convertWord(bytes, output);
                    break;
                case "pptx":
                case "ppt":
                    convertPpt(bytes, output);
                    break;
                case "ofd":
                    convertOfdToPdf(bytes, output);
                    break;
                default:
                    throw new LobsterException("preview.unsupported", "internal: unhandled ext " + e);
            }
        } catch (LobsterException le) {
            // 转换失败要清理半成品，否则下次命中坏缓存
            tryDelete(outFile);
            throw le;
        } catch (Throwable t) {
            tryDelete(outFile);
            throw new LobsterException("preview.convert_failed",
                    "Office 转换失败 (ext=" + e + " → " + outputExt(e) + "): " + safeMsg(t), t);
        }

        // 转换成功但输出文件为空——异常，丢出去防 caller 拿到无效缓存
        if (!outFile.exists() || outFile.length() == 0) {
            tryDelete(outFile);
            throw new LobsterException("preview.convert_failed",
                    "Spire 转换返回，但输出文件为空 (ext=" + e + ")");
        }
        long ms = System.currentTimeMillis() - t0;
        try { Tools.log("[OfficePreview] converted " + cacheKey + " in " + ms + "ms output=" + outFile.length() + "B"); } catch (Throwable ignore) { /* ignore */ }
        return outFile;
    }

    // ---- 私有：实际 Spire 调用 ----

    /** xlsx/xls → HTML（保留 frameset + _files 子目录的多 sheet 切换；office-asset 端点配合）. */
    private void convertExcel(byte[] bytes, Path output) throws IOException {
        configureSpireFonts();
        Workbook wb = new Workbook();
        try {
            wb.loadFromStream(new ByteArrayInputStream(bytes));
            Files.createDirectories(output.getParent());
            wb.saveToFile(output.toString(), FileFormat.HTML);
        } finally {
            try { wb.dispose(); } catch (Throwable ignore) { /* ignore */ }
        }
    }

    /** docx/doc/wps → PDF（之前用 HTML，复杂表 / 公式 / 页眉容易跑偏；PDF 保真度高）. */
    private void convertWord(byte[] bytes, Path output) throws IOException {
        configureSpireFonts();
        com.spire.doc.Document doc = new com.spire.doc.Document();
        try {
            if (spireFontDir != null) {
                doc.setCustomFontsFolders(spireFontDir);
            }
            doc.setDefaultSubstitutionFontName("SimSun");
            doc.loadFromStream(new ByteArrayInputStream(bytes), com.spire.doc.FileFormat.Auto);
            Files.createDirectories(output.getParent());
            doc.saveToFile(output.toString(), com.spire.doc.FileFormat.PDF);
        } finally {
            try { doc.close(); } catch (Throwable ignore) { /* ignore */ }
        }
    }

    /** pptx/ppt → PDF（一页对一页，浏览器 viewer 自带缩略图导航；HTML 是每页一个文件 + frameset 索引，太碎）. */
    private void convertPpt(byte[] bytes, Path output) throws IOException {
        configureSpireFonts();
        Presentation ppt = new Presentation();
        try {
            ppt.loadFromStream(new ByteArrayInputStream(bytes), com.spire.presentation.FileFormat.AUTO);
            Files.createDirectories(output.getParent());
            ppt.saveToFile(output.toString(), com.spire.presentation.FileFormat.PDF);
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            try { ppt.dispose(); } catch (Throwable ignore) { /* ignore */ }
        }
    }

    /** ofd → PDF：Spire OfdConverter 直接输出 PDF. */
    private void convertOfdToPdf(byte[] bytes, Path output) throws IOException {
        com.spire.pdf.conversion.OfdConverter conv = new com.spire.pdf.conversion.OfdConverter(new ByteArrayInputStream(bytes));
        try {
            Files.createDirectories(output.getParent());
            conv.toPdf(output.toString());
        } finally {
            try { conv.dispose(); } catch (Throwable ignore) { /* ignore */ }
        }
    }

    private Path resolveCacheDir() {
        // 复用 ContentStore 的 base，缓存跟生成内容同一个磁盘根，运维好统一管理
        String base = LobsterConfig.getContentStoreBase();
        if (base == null || base.isEmpty()) base = System.getProperty("user.dir", ".");
        Path dir = Paths.get(base, CACHE_SUBDIR).toAbsolutePath().normalize();
        try {
            Files.createDirectories(dir);
        } catch (IOException e) {
            throw new LobsterException("preview.cache_init", "Cannot create office-preview cache dir: " + dir, e);
        }
        return dir;
    }

    private void tryDelete(File f) {
        if (f == null || !f.exists()) return;
        try { Files.deleteIfExists(f.toPath()); } catch (Throwable ignore) { /* ignore */ }
    }

    private void configureSpireFonts() {
        if (!SPIRE_FONTS_CONFIGURED.compareAndSet(false, true)) return;
        Path dir = resolveFontDir();
        if (dir == null) {
            try { Tools.log("[OfficePreview] no custom font dir found; Spire will use system fonts"); } catch (Throwable ignore) { /* ignore */ }
            return;
        }
        spireFontDir = dir.toString();
        try {
            com.spire.doc.Document.clearSystemFontCache();
            com.spire.doc.Document.setGlobalCustomFontsFolders(spireFontDir);
            Presentation.setCustomFontsFolder(spireFontDir);
            try { Tools.log("[OfficePreview] Spire custom font dir: " + spireFontDir); } catch (Throwable ignore) { /* ignore */ }
        } catch (Throwable t) {
            try { Tools.log("[OfficePreview] configure Spire fonts failed: " + spireFontDir, t); } catch (Throwable ignore) { /* ignore */ }
        }
    }

    private Path resolveFontDir() {
        String explicit = firstNonEmpty(
                System.getProperty("lobster.officePreviewFontDir"),
                System.getenv("LOBSTER_OFFICE_PREVIEW_FONT_DIR"));
        if (explicit != null) {
            Path p = Paths.get(explicit).toAbsolutePath().normalize();
            if (isFontDir(p)) return p;
            try { Tools.log("[OfficePreview] configured font dir has no fonts: " + p); } catch (Throwable ignore) { /* ignore */ }
        }

        String catalinaBase = System.getProperty("catalina.base");
        String catalinaHome = System.getProperty("catalina.home");
        String userDir = System.getProperty("user.dir", ".");
        Path[] candidates = new Path[] {
                Paths.get(userDir, "WEB-INF", "fonts"),
                Paths.get(userDir, "web", "WEB-INF", "fonts"),
                catalinaBase == null ? null : Paths.get(catalinaBase, "webapps", "ROOT", "WEB-INF", "fonts"),
                catalinaHome == null ? null : Paths.get(catalinaHome, "webapps", "ROOT", "WEB-INF", "fonts"),
                Paths.get("/usr/local/tomcat/webapps/ROOT/WEB-INF/fonts")
        };
        for (Path candidate : candidates) {
            if (candidate == null) continue;
            Path p = candidate.toAbsolutePath().normalize();
            if (isFontDir(p)) return p;
        }
        return null;
    }

    private boolean isFontDir(Path dir) {
        if (dir == null || !Files.isDirectory(dir)) return false;
        try (Stream<Path> files = Files.list(dir)) {
            return files.anyMatch(p -> {
                String name = p.getFileName() == null ? "" : p.getFileName().toString().toLowerCase(Locale.ROOT);
                return Files.isRegularFile(p)
                        && (name.endsWith(".ttf") || name.endsWith(".ttc") || name.endsWith(".otf"));
            });
        } catch (IOException e) {
            return false;
        }
    }

    private String safeMsg(Throwable t) {
        String m = t == null ? null : t.getMessage();
        return m == null ? (t == null ? "unknown" : t.getClass().getSimpleName()) : m;
    }

    private String firstNonEmpty(String... ss) {
        for (String s : ss) {
            if (s != null && !s.isEmpty()) return s;
        }
        return null;
    }
}
