package com.gzzm.lobster.preview;

import com.gzzm.lobster.artifact.Artifact;
import com.gzzm.lobster.artifact.ArtifactService;
import com.gzzm.lobster.common.LobsterException;
import com.gzzm.lobster.common.ResourceSourceType;
import com.gzzm.lobster.identity.UserContext;
import com.gzzm.lobster.identity.UserContextHolder;
import com.gzzm.lobster.oa.OaFileClient;
import com.gzzm.lobster.storage.FileSystemContentStore;
import com.gzzm.lobster.workspace.ResourceMetadata;
import com.gzzm.lobster.workspace.WorkspaceResource;
import com.gzzm.lobster.workspace.WorkspaceService;
import com.gzzm.platform.annotation.NoLogin;
import net.cyan.arachne.annotation.Service;
import net.cyan.commons.util.io.DownloadFile;
import net.cyan.nest.annotation.Inject;

import java.io.File;
import java.util.Locale;
import java.util.Map;

/**
 * OfficePreviewApi —— 把 docx/xlsx/pptx/ofd 转成 HTML 内联预览给 iframe.
 *
 * <p>两个端点对称：
 * <ul>
 *   <li>{@code GET /ai/api/artifacts/{artifactId}/office-html} —— ARTIFACT / WORKSHOP_DOC 工件</li>
 *   <li>{@code GET /ai/api/uploads/{resourceId}/office-html}   —— USER_UPLOAD / OA_FILE 资源原件</li>
 * </ul>
 *
 * <p>权限模型与各自的 {@code /preview} / {@code /inline} 端点对齐：artifact 比对 userId；
 * upload 同样比 userId 且强制 sourceType=USER_UPLOAD/OA_FILE.
 *
 * <p>缓存键：
 * <ul>
 *   <li>artifact 用 {@code "art-{id}-v{version}"}：version 在 overwrite 时自增，键随之失效不冲撞</li>
 *   <li>upload 用 {@code "up-{resourceId}"}：上传文件不可变，resourceId 唯一</li>
 * </ul>
 *
 * <p>设计注意：Spire 单线程不安全 + 慢，目前的同步 doGet 模型在并发场景下可能阻塞 servlet 线程；
 * 真要扛量得包一层 executor + 等待 future. 当前预览是用户主动点触发，QPS 低，先用同步.
 */
@Service
public class OfficePreviewApi {

    private static final String PDF_PREVIEW_CACHE_VERSION = "pv2";

    @Inject private ArtifactService artifactService;
    @Inject private WorkspaceService workspaceService;
    @Inject private FileSystemContentStore contentStore;
    @Inject private OfficePreviewService officePreviewService;
    @Inject private OaFileClient oaFileClient;

    /** ARTIFACT / WORKSHOP_DOC 走这里. */
    @Service(url = "/ai/api/artifacts/{$0}/office-html")
    public DownloadFile artifactOfficeHtml(String artifactId) throws Exception {
        UserContext user = UserContextHolder.require();
        Artifact a = artifactService.get(artifactId);
        if (a == null) throw new LobsterException("artifact.not_found", "Artifact not found");
        if (!a.getUserId().equals(user.getUserId())) {
            throw new LobsterException("artifact.forbidden", "Artifact not owned");
        }
        String ext = (a.getFormat() == null ? "" : a.getFormat()).toLowerCase(Locale.ROOT);
        if (!OfficePreviewService.supports(ext)) {
            throw new LobsterException("preview.unsupported",
                    "Office preview not supported for format: " + ext);
        }
        String cacheKey = previewCacheKey("art-" + safeKey(artifactId) + "-v" + a.getVersion(), ext);
        // 缓存命中：跳过 readArtifactBytes，几 MB 文件白读一次磁盘 + 装内存挺浪费.
        // version 在 overwrite 时自增，缓存 key 随之失效，不会拿到陈旧内容.
        File cached = officePreviewService.cachedFile(cacheKey, ext);
        if (cached != null) {
            return wrap(cached, ext, a.getTitle(), cacheKey);
        }
        byte[] bytes = artifactService.readArtifactBytes(artifactId);
        if (bytes == null || bytes.length == 0) {
            throw new LobsterException("artifact.empty", "Artifact bytes empty: " + artifactId);
        }
        File rendered = officePreviewService.renderHtml(cacheKey, ext, bytes);
        return wrap(rendered, ext, a.getTitle(), cacheKey);
    }

    /** USER_UPLOAD / OA_FILE 走这里. */
    @Service(url = "/ai/api/uploads/{$0}/office-html")
    public DownloadFile uploadOfficeHtml(String resourceId) throws Exception {
        UserContext user = UserContextHolder.require();
        if (resourceId == null || resourceId.isEmpty()) {
            throw new LobsterException("upload.invalid", "resourceId required");
        }
        WorkspaceResource r = workspaceService.getResource(resourceId);
        if (r == null) throw new LobsterException("upload.not_found", "Resource not found: " + resourceId);
        if (!user.getUserId().equals(r.getUserId())) {
            throw new LobsterException("upload.forbidden", "No permission to view: " + resourceId);
        }
        if (!isResourceOriginalSupported(r)) {
            throw new LobsterException("upload.invalid_type",
                    "Office preview is only for USER_UPLOAD/OA_FILE, got " + r.getSourceType());
        }
        Map<String, Object> meta = ResourceMetadata.readMap(r.getMetadataJson());
        // 扩展名优先看 metadata.kind（上传时存的），其次按 displayName / originalName 兜底
        String ext = pickExt(meta, r);
        if (!OfficePreviewService.supports(ext)) {
            throw new LobsterException("preview.unsupported",
                    "Office preview not supported for format: " + ext);
        }
        String title = firstNonEmpty(asString(meta.get("originalName")), r.getDisplayName(), resourceId);
        String cacheKey = previewCacheKey(resourceCachePrefix(r) + safeKey(resourceId), ext);
        // 缓存命中：跳过 contentStore.readBinary（同 artifact 路径，避免大文件白读）
        File cached = officePreviewService.cachedFile(cacheKey, ext);
        if (cached != null) {
            return wrap(cached, ext, title, cacheKey);
        }
        // 缓存 miss 才取 origRef / OA bytes 和读字节
        byte[] bytes = readOriginalBytes(user, r, meta);
        if (bytes == null || bytes.length == 0) {
            throw new LobsterException("upload.origin_missing", "Original file missing: " + resourceId);
        }
        File rendered = officePreviewService.renderHtml(cacheKey, ext, bytes);
        return wrap(rendered, ext, title, cacheKey);
    }

    // ============== Office 辅助资源（_files/ 子目录） ==============
    //
    // Spire 把 xlsx/pptx 转 HTML 时实际产出是顶层 frameset + 同名 _files 子目录，
    // 子目录里是各 sheet/页的 HTML（含 tabs.html 之类）. iframe 加载顶层 frameset 后，
    // 浏览器按相对 URL 去拉子文件，路径形如：
    //   /ai/api/uploads/{resourceId}/{cacheKey}_files/{sheetName}.{ext}
    //   /ai/api/artifacts/{artifactId}/{cacheKey}_files/{sheetName}.{ext}
    // 这两个端点专门接住浏览器自动发起的子资源请求.

    /**
     * ARTIFACT / WORKSHOP_DOC 的 office 子资源.
     *
     * <p>{@code @NoLogin}：iframe sandbox 没开 allow-same-origin 时，frameset 内的 frame
     * 子请求会被浏览器当成 cross-origin，session cookie 不会带过来 → 走 require()
     * 必抛 "No authenticated UserContext on current thread". 参考 ideepseek FilePreview 同款做法
     * 跳过 auth；安全靠路径中的 artifactId 不可猜（uuid）+ filesDir 必须严格匹配 cacheKey 兜底.
     *
     * <p>**注意**：主端点 {@link #artifactOfficeHtml} 仍走 auth；attacker 没法触发新的转换，
     * 只能读已经存在的缓存. 真要更稳就改用 signed URL（带签名 token + 过期时间），但当前对
     * 非敏感 office 预览场景这点 obscurity 够用了.
     */
    @NoLogin
    @Service(url = "/ai/api/artifacts/{$0}/{$1}/{$2}.{ext}")
    public DownloadFile artifactOfficeAsset(String artifactId, String filesDir,
                                            String name, String ext) throws Exception {
        // filesDir 必须形如 art-{safeArtifactId}-v{N}_files，不依赖 DB 也能挡住乱填的路径
        if (filesDir == null || !filesDir.startsWith("art-" + safeKey(artifactId) + "-v")
                || !filesDir.endsWith("_files")) {
            throw new LobsterException("preview.invalid_asset", "office asset path mismatch");
        }
        return loadAsset(filesDir, name, ext);
    }

    /** USER_UPLOAD / OA_FILE 的 office 子资源.（同 {@link #artifactOfficeAsset} 的 @NoLogin 取舍） */
    @NoLogin
    @Service(url = "/ai/api/uploads/{$0}/{$1}/{$2}.{ext}")
    public DownloadFile uploadOfficeAsset(String resourceId, String filesDir,
                                          String name, String ext) throws Exception {
        // filesDir 必须严格等于 up-{safeResourceId}_files 或 oa-{safeResourceId}_files
        String safe = safeKey(resourceId);
        String uploadDir = "up-" + safe + "_files";
        String oaDir = "oa-" + safe + "_files";
        if (!uploadDir.equals(filesDir) && !oaDir.equals(filesDir)) {
            throw new LobsterException("preview.invalid_asset", "office asset path mismatch");
        }
        return loadAsset(filesDir, name, ext);
    }

    // ---- 私有工具 ----

    /**
     * 把转出来的 File 包成 DownloadFile 返浏览器.
     * docx/pptx/ofd 输出 PDF（mime application/pdf），xlsx 输出 HTML.
     *
     * <p>用 {@code DownloadFile(File, ...)} 构造器而不是先 readAllBytes：
     * 框架内部会用 FileInputStream + 流式写到 response，命中缓存时不必再把整文件
     * 读进 JVM heap 然后再吐出去，省一轮内存拷贝.
     */
    private DownloadFile wrap(File file, String ext, String title, String cacheKey) {
        boolean isPdf = officePreviewService.outputIsPdf(ext);
        String mime = isPdf ? "application/pdf" : "text/html; charset=UTF-8";
        String safeTitle = title == null || title.isEmpty() ? cacheKey : title;
        String fileName = safeTitle + (isPdf ? ".pdf" : ".html");
        DownloadFile df = new DownloadFile(file, fileName, mime);
        df.setAttachment(false);  // inline，让 iframe 直接渲染
        return df;
    }

    /**
     * 把 _files 子目录里的资源包成 DownloadFile.
     * 所有 asset 都按文件名扩展名猜 mime；不在白名单的 ext 一律 octet-stream.
     */
    private DownloadFile loadAsset(String filesDir, String name, String ext) {
        File f = officePreviewService.cachedAsset(filesDir, name + "." + ext);
        if (f == null) {
            throw new LobsterException("preview.asset_not_found",
                    "office asset not found: " + filesDir + "/" + name + "." + ext);
        }
        DownloadFile df = new DownloadFile(f, name + "." + ext, mimeOfAsset(ext));
        df.setAttachment(false);
        return df;
    }

    /** Spire 子目录里 99% 是 html / png / jpg / gif / css / js；其它一律 octet-stream. */
    private String mimeOfAsset(String ext) {
        if (ext == null) return "application/octet-stream";
        switch (ext.toLowerCase(Locale.ROOT)) {
            case "html": case "htm": return "text/html; charset=UTF-8";
            case "css":  return "text/css; charset=UTF-8";
            case "js":   return "application/javascript; charset=UTF-8";
            case "png":  return "image/png";
            case "jpg": case "jpeg": return "image/jpeg";
            case "gif":  return "image/gif";
            case "svg":  return "image/svg+xml";
            case "webp": return "image/webp";
            default:     return "application/octet-stream";
        }
    }

    /** displayName 不可靠时兜底取 metadata.kind；都没的话从 originalName 末尾抽扩展名. */
    private String pickExt(Map<String, Object> meta, WorkspaceResource r) {
        String kind = asString(meta.get("kind"));
        if (kind != null && !kind.isEmpty()) return kind.toLowerCase(Locale.ROOT);
        for (String name : new String[] { r.getDisplayName(), asString(meta.get("originalName")) }) {
            if (name == null) continue;
            int dot = name.lastIndexOf('.');
            if (dot >= 0 && dot < name.length() - 1) {
                return name.substring(dot + 1).toLowerCase(Locale.ROOT);
            }
        }
        return "";
    }

    private boolean isResourceOriginalSupported(WorkspaceResource r) {
        return r != null && (r.getSourceType() == ResourceSourceType.USER_UPLOAD
                || r.getSourceType() == ResourceSourceType.OA_FILE);
    }

    private String resourceCachePrefix(WorkspaceResource r) {
        return r != null && r.getSourceType() == ResourceSourceType.OA_FILE ? "oa-" : "up-";
    }

    private String previewCacheKey(String base, String ext) {
        if (officePreviewService.outputIsPdf(ext)) {
            return base + "-" + PDF_PREVIEW_CACHE_VERSION;
        }
        return base;
    }

    private byte[] readOriginalBytes(UserContext user, WorkspaceResource r, Map<String, Object> meta) throws Exception {
        String origRef = asString(meta.get("origRef"));
        if (origRef != null && !origRef.isEmpty()) {
            byte[] bytes = contentStore.readBinary(origRef);
            if (bytes == null) {
                throw new LobsterException("upload.origin_missing", "Original file missing on disk: " + origRef);
            }
            return bytes;
        }
        if (r.getSourceType() == ResourceSourceType.OA_FILE) {
            return oaFileClient.downloadBytes(user, r.getSourceId());
        }
        throw new LobsterException("upload.no_origin", "No original file for " + r.getResourceId());
    }

    /** 把 ID 里可能出现的非文件名安全字符干掉，避免拼出 ../../ 之类的缓存 key. */
    private String safeKey(String s) {
        if (s == null) return "_";
        return s.replaceAll("[^A-Za-z0-9_\\-]", "_");
    }

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

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