package com.gzzm.lobster.skill;

import com.gzzm.lobster.common.JsonUtil;
import com.gzzm.lobster.common.LobsterException;
import com.gzzm.lobster.config.LobsterConfig;
import com.gzzm.lobster.storage.FileSystemContentStore;
import com.gzzm.platform.commons.Tools;
import net.cyan.arachne.annotation.Service;
import net.cyan.nest.annotation.Inject;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;

import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

/**
 * SkillAssetService —— Skill 资产包（tar.gz）生命周期.
 *
 * <p>三条路径：
 * <ul>
 *   <li>{@link #validateBundle(byte[], Set)} —— AdminSkillBundleApi 上传时做 §7.3 全部 8 项安全校验</li>
 *   <li>{@link #ensureExtracted(String)} —— 运行时按 skillId 定位 bundle、解压到本地缓存、返回可挂载的 host 路径</li>
 *   <li>{@link #invalidate(String)} —— 版本升级 / 禁用后清缓存</li>
 * </ul>
 *
 * <p>缓存目录：{@link LobsterConfig#getSandboxBundleCacheDir()}/{skillId}/v{version}/.
 */
@Service
public class SkillAssetService {

    @Inject private SkillDefinitionDao skillDao;
    @Inject private FileSystemContentStore contentStore;

    /** key=skillId+":"+version → extracted/mirrored bundle path. */
    private final ConcurrentHashMap<String, Path> extractedCache = new ConcurrentHashMap<>();
    /**
     * LRU 访问顺序表：key → 最近访问时间戳 (ms). 每次 ensureExtracted 命中都更新.
     * 配合 {@link #enforceCacheLimit} 淘汰最老的 non-system 条目.
     */
    private final ConcurrentHashMap<String, Long> lastAccess = new ConcurrentHashMap<>();

    /** thunwind DAO 跨线程保护 */
    private SkillDefinitionDao skillDao() {
        try {
            SkillDefinitionDao d = Tools.getBean(SkillDefinitionDao.class);
            if (d != null) return d;
        } catch (Throwable ignore) { /* fallback */ }
        return skillDao;
    }

    /**
     * 按 skillId 确保 bundle 已解压到本地缓存.
     *
     * @return 缓存目录 host 绝对路径；可直接挂为 {@code /skill/<skillId>:ro}
     */
    public Path ensureExtracted(String skillId) throws Exception {
        if (skillId == null || skillId.isEmpty()) return null;
        SkillDefinition def = skillDao().getSkill(skillId);
        if (def == null) throw new LobsterException("skill.not_found", "Skill not found: " + skillId);
        if (Boolean.FALSE.equals(def.getEnabled())) {
            throw new LobsterException("skill.disabled", "Skill disabled: " + skillId);
        }
        if (def.getAssetBundleRef() == null || def.getAssetBundleRef().isEmpty()) {
            return null;
        }
        int ver = def.getVersion() == null ? 1 : def.getVersion();
        String key = skillId + ":" + ver;
        String ref = def.getAssetBundleRef();
        // 系统 skill 源目录通常在 Tomcat webapp 下。生产用 DinD daemon 创建沙箱容器时，
        // Docker bind mount 的 source path 必须在 DinD 容器文件系统里也可见；Tomcat webapp
        // 路径只对 Tomcat 可见，会被 DinD 当成空目录创建，导致 /skill/<id>/scripts 缺失。
        // 因此 fs:// 系统 skill 先镜像到 sandboxBundleCacheDir（compose 中 Tomcat 与 DinD 共享），
        // 再把共享路径挂给沙箱容器。
        if (ref.startsWith("fs://")) {
            Path fsPath = Paths.get(ref.substring("fs://".length())).toAbsolutePath();
            if (!Files.isDirectory(fsPath)) {
                throw new LobsterException("skill.bundle_missing",
                        "system skill directory not found: " + fsPath);
            }
            Path mirrored = ensureSystemSkillMirrored(skillId, key, fsPath, ver);
            extractedCache.put(key, mirrored);
            lastAccess.put(key, System.currentTimeMillis());
            return mirrored;
        }

        Path cached = extractedCache.get(key);
        if (cached != null && Files.exists(cached)) {
            lastAccess.put(key, System.currentTimeMillis());
            return cached;
        }

        Path cacheRoot = Paths.get(LobsterConfig.getSandboxBundleCacheDir(), skillId, "v" + ver).toAbsolutePath();
        if (Files.exists(cacheRoot) && !isEmpty(cacheRoot)) {
            extractedCache.put(key, cacheRoot);
            return cacheRoot;
        }
        byte[] tarGz = contentStore.readBinary(def.getAssetBundleRef());
        if (tarGz == null) {
            throw new LobsterException("skill.bundle_missing",
                    "bundle content missing: " + def.getAssetBundleRef());
        }
        Path tmp = Paths.get(LobsterConfig.getSandboxBundleCacheDir(), skillId, "v" + ver + "-tmp-" + System.nanoTime()).toAbsolutePath();
        Files.createDirectories(tmp);
        try {
            extract(tarGz, tmp);
            Files.createDirectories(cacheRoot.getParent());
            if (Files.exists(cacheRoot)) deleteRecursive(cacheRoot);
            Files.move(tmp, cacheRoot, StandardCopyOption.ATOMIC_MOVE);
        } catch (Exception e) {
            try { deleteRecursive(tmp); } catch (Throwable ignore) { /* best effort */ }
            throw e;
        }
        extractedCache.put(key, cacheRoot);
        lastAccess.put(key, System.currentTimeMillis());
        // 新 bundle 落盘后按配置的 entry / byte 上限淘汰老的
        enforceCacheLimit();
        return cacheRoot;
    }

    /**
     * LRU 淘汰：按 last-access 时间从旧到新排序，删到满足
     * {@link LobsterConfig#getSandboxBundleCacheMaxEntries()} 和
     * {@link LobsterConfig#getSandboxBundleCacheMaxBytes()} 两个维度.
     *
     * <p>跳过系统 skill（fs:// 前缀的 path 不在 bundle cache 根下）—— 它们是文件系统
     * 绑定的原始目录，淘汰无意义；只淘汰 bundle 解压副本.
     */
    private void enforceCacheLimit() {
        int maxEntries = LobsterConfig.getSandboxBundleCacheMaxEntries();
        long maxBytes = LobsterConfig.getSandboxBundleCacheMaxBytes();
        Path cacheRoot = Paths.get(LobsterConfig.getSandboxBundleCacheDir()).toAbsolutePath().normalize();
        // 快照 + 按访问时间升序（最旧的先）
        List<Map.Entry<String, Long>> entries = new ArrayList<>(lastAccess.entrySet());
        entries.sort(Comparator.comparingLong(Map.Entry::getValue));
        // 算可淘汰的条目（只 non-system）+ 每个占多少字节
        int evictable = 0;
        long totalBytes = 0;
        Map<String, Long> sizes = new LinkedHashMap<>();
        for (Map.Entry<String, Long> e : entries) {
            Path p = extractedCache.get(e.getKey());
            if (p == null) continue;
            if (e.getKey().startsWith("sys_")) continue; // 系统 skill mirror 不做 LRU 淘汰
            if (!p.toAbsolutePath().normalize().startsWith(cacheRoot)) continue;
            long sz = dirSize(p);
            sizes.put(e.getKey(), sz);
            totalBytes += sz;
            evictable++;
        }
        if (evictable <= maxEntries && totalBytes <= maxBytes) return;
        // 按访问时间升序扫，淘汰直到两个维度都达标
        for (Map.Entry<String, Long> e : entries) {
            if (evictable <= maxEntries && totalBytes <= maxBytes) break;
            String k = e.getKey();
            if (!sizes.containsKey(k)) continue;
            Path p = extractedCache.remove(k);
            lastAccess.remove(k);
            Long sz = sizes.get(k);
            totalBytes -= sz == null ? 0 : sz;
            evictable--;
            if (p != null) {
                try { deleteRecursive(p); } catch (Throwable t) {
                    try { Tools.log("[SkillAssetService] LRU evict delete failed: " + p, t); }
                    catch (Throwable ignore) { /* ignore */ }
                }
            }
        }
    }

    private Path ensureSystemSkillMirrored(String skillId, String key, Path source, int version) throws IOException {
        Path mirror = Paths.get(LobsterConfig.getSandboxBundleCacheDir(),
                "_system", skillId, "v" + version).toAbsolutePath().normalize();
        Path marker = mirror.resolve(".lobster-source-fingerprint");
        String fingerprint = fingerprint(source);
        Path cached = extractedCache.get(key);
        if (cached != null && Files.exists(cached)
                && Files.exists(marker)
                && fingerprint.equals(new String(Files.readAllBytes(marker), StandardCharsets.UTF_8))) {
            return cached;
        }
        if (Files.exists(mirror)
                && Files.exists(marker)
                && fingerprint.equals(new String(Files.readAllBytes(marker), StandardCharsets.UTF_8))) {
            return mirror;
        }

        Path tmp = mirror.getParent().resolve("v" + version + "-tmp-" + System.nanoTime());
        Files.createDirectories(tmp);
        try {
            copyDirectory(source, tmp);
            Files.write(tmp.resolve(".lobster-source-fingerprint"),
                    fingerprint.getBytes(StandardCharsets.UTF_8));
            Files.createDirectories(mirror.getParent());
            if (Files.exists(mirror)) deleteRecursive(mirror);
            Files.move(tmp, mirror, StandardCopyOption.ATOMIC_MOVE);
        } catch (IOException | RuntimeException e) {
            try { deleteRecursive(tmp); } catch (Throwable ignore) { /* best effort */ }
            throw e;
        }
        return mirror;
    }

    private static void copyDirectory(Path source, Path target) throws IOException {
        Path sourceAbs = source.toAbsolutePath().normalize();
        try (java.util.stream.Stream<Path> stream = Files.walk(sourceAbs)) {
            java.util.Iterator<Path> it = stream.iterator();
            while (it.hasNext()) {
                Path p = it.next();
                Path rel = sourceAbs.relativize(p);
                Path out = target.resolve(rel.toString()).normalize();
                if (!out.startsWith(target)) continue;
                if (Files.isDirectory(p)) {
                    Files.createDirectories(out);
                } else if (Files.isRegularFile(p)) {
                    if (out.getParent() != null) Files.createDirectories(out.getParent());
                    Files.copy(p, out, StandardCopyOption.REPLACE_EXISTING);
                }
            }
        }
    }

    private static String fingerprint(Path source) throws IOException {
        final long[] count = {0L};
        final long[] bytes = {0L};
        final long[] newest = {0L};
        Path sourceAbs = source.toAbsolutePath().normalize();
        try (java.util.stream.Stream<Path> stream = Files.walk(sourceAbs)) {
            java.util.Iterator<Path> it = stream.iterator();
            while (it.hasNext()) {
                Path p = it.next();
                if (!Files.isRegularFile(p)) continue;
                count[0]++;
                bytes[0] += Files.size(p);
                newest[0] = Math.max(newest[0], Files.getLastModifiedTime(p).toMillis());
            }
        }
        return sourceAbs + "\n" + count[0] + "\n" + bytes[0] + "\n" + newest[0] + "\n";
    }

    private static long dirSize(Path dir) {
        try (java.util.stream.Stream<Path> w = Files.walk(dir)) {
            return w.filter(Files::isRegularFile).mapToLong(p -> {
                try { return Files.size(p); } catch (IOException e) { return 0L; }
            }).sum();
        } catch (IOException e) {
            return 0L;
        }
    }

    public void invalidate(String skillId) {
        if (skillId == null || skillId.isEmpty()) return;
        Path cacheRoot = Paths.get(LobsterConfig.getSandboxBundleCacheDir()).toAbsolutePath().normalize();
        List<String> toRemove = new ArrayList<>();
        for (String k : extractedCache.keySet()) {
            if (k.startsWith(skillId + ":")) toRemove.add(k);
        }
        for (String k : toRemove) {
            Path p = extractedCache.remove(k);
            lastAccess.remove(k);
            if (p == null) continue;
            // 只删 bundle cache 目录下的副本；系统 skill 的 fs:// 原目录不能碰
            Path abs = p.toAbsolutePath().normalize();
            if (!abs.startsWith(cacheRoot)) continue;
            try { deleteRecursive(abs); } catch (Throwable ignore) { /* ignore */ }
        }
    }

    // ===== §7.3 Bundle 解压安全 =====

    private static final long MAX_FILE_BYTES = 5L * 1024 * 1024;
    private static final long MAX_TOTAL_BYTES = 50L * 1024 * 1024;
    private static final int MAX_FILE_COUNT = 1000;
    private static final int MAX_COMPRESSION_RATIO = 100;
    private static final long MAX_SKILL_MD_BYTES = 1024L * 1024;

    /** §7.3 8 项校验结果. 通过则 {@link ValidationResult#ok} = true. */
    public static final class ValidationResult {
        public boolean ok;
        public String code;
        public String message;
        public boolean hasSkillMd;
        public long totalBytes;
        public int fileCount;
        public List<String> declaredPythonPackages = java.util.Collections.emptyList();

        public static ValidationResult reject(String code, String msg) {
            ValidationResult v = new ValidationResult();
            v.ok = false; v.code = code; v.message = msg; return v;
        }
        public static ValidationResult accept() {
            ValidationResult v = new ValidationResult();
            v.ok = true; return v;
        }
    }

    /**
     * §7.3 bundle 上传校验 —— 只校验，不落盘；通过后调用方再落 ContentStore.
     *
     * @param tarGz      上传的压缩包字节
     * @param allowedPkgs 沙箱镜像预装 pip 包白名单；null = 跳过 pip 校验（仅调试期）
     */
    public ValidationResult validateBundle(byte[] tarGz, Set<String> allowedPkgs) {
        if (tarGz == null || tarGz.length == 0) {
            return ValidationResult.reject("bundle.empty", "empty bundle");
        }
        ValidationResult v = new ValidationResult();
        v.ok = true;
        long totalSize = 0;
        int count = 0;
        long metadataSize = 0;
        byte[] metadataBytes = null;

        try (BufferedInputStream bis = new BufferedInputStream(new ByteArrayInputStream(tarGz));
             GzipCompressorInputStream gz = new GzipCompressorInputStream(bis);
             TarArchiveInputStream tar = new TarArchiveInputStream(gz)) {
            TarArchiveEntry e;
            while ((e = tar.getNextTarEntry()) != null) {
                String name = e.getName();
                if (e.isSymbolicLink() || e.isLink()) {
                    return ValidationResult.reject("bundle.symlink_forbidden",
                            "symlinks not allowed: " + name);
                }
                if (name == null || name.isEmpty() || name.startsWith("/")
                        || name.contains("..") || name.contains("\\")
                        || name.contains(":")) {
                    return ValidationResult.reject("bundle.path_traversal",
                            "illegal path: " + name);
                }
                if (e.isDirectory()) continue;
                long size = e.getSize();
                if (size > MAX_FILE_BYTES) {
                    return ValidationResult.reject("bundle.file_too_large",
                            "file too large: " + name + " (" + size + " bytes)");
                }
                totalSize += size;
                count++;
                if (count > MAX_FILE_COUNT) {
                    return ValidationResult.reject("bundle.too_many_files",
                            "too many files (> " + MAX_FILE_COUNT + ")");
                }
                if (totalSize > MAX_TOTAL_BYTES) {
                    return ValidationResult.reject("bundle.bomb_size",
                            "uncompressed total too large (> " + MAX_TOTAL_BYTES + ")");
                }
                // 严格要求 SKILL.md 在 bundle 根目录 —— 运行时 bundle 挂载到 /skill/<id>/，
                // 如果允许嵌套在 foo/SKILL.md，LLM 按文档找 /skill/<id>/SKILL.md 会落空.
                // 任何 name 含 '/' 的 SKILL.md 都拒；给上传者明确信号："bundle 根就是 SKILL.md".
                if ("SKILL.md".equals(name)) {
                    if (size > MAX_SKILL_MD_BYTES) {
                        return ValidationResult.reject("bundle.skill_md_too_large",
                                "SKILL.md exceeds 1MB");
                    }
                    v.hasSkillMd = true;
                } else if (name.endsWith("/SKILL.md")) {
                    return ValidationResult.reject("bundle.skill_md_nested",
                            "SKILL.md must be at bundle root, got: " + name
                                    + " (strip the top-level directory before re-uploading)");
                }
                if ("metadata.yml".equals(name) || name.endsWith("/metadata.yml")) {
                    metadataSize = size;
                    metadataBytes = readAll(tar, (int) size);
                }
            }
        } catch (IOException ioe) {
            return ValidationResult.reject("bundle.unreadable", "cannot read bundle: " + ioe.getMessage());
        }
        v.totalBytes = totalSize;
        v.fileCount = count;
        if (!v.hasSkillMd) {
            return ValidationResult.reject("bundle.missing_skill_md", "SKILL.md required");
        }
        long compressed = tarGz.length;
        if (compressed > 0 && totalSize / Math.max(1L, compressed) > MAX_COMPRESSION_RATIO) {
            return ValidationResult.reject("bundle.bomb_ratio",
                    "compression ratio > " + MAX_COMPRESSION_RATIO);
        }
        // metadata 里可能声明 pythonPackages.
        List<String> declared = (metadataBytes != null && metadataSize > 0)
                ? extractPythonPackages(metadataBytes) : java.util.Collections.<String>emptyList();
        v.declaredPythonPackages = declared;
        if (allowedPkgs != null) {
            for (String p : declared) {
                String base = baseName(p);
                if (!allowedPkgs.contains(base)) {
                    return ValidationResult.reject("bundle.pip_not_in_image",
                            "python package not in image: " + p);
                }
            }
        } else if (!declared.isEmpty()
                && LobsterConfig.isSandboxRequireInstalledPackagesFile()) {
            // fail-closed：声明了 pip 包但找不到白名单 → 拒绝；生产默认开启.
            return ValidationResult.reject("bundle.whitelist_missing",
                    "pythonPackages declared but installed-packages whitelist is not configured; "
                            + "deploy " + LobsterConfig.getSandboxInstalledPackagesFile()
                            + " or set sandboxRequireInstalledPackagesFile=false for dev");
        }
        return v;
    }

    /** 原始解压实现 —— validateBundle 通过后落盘调用. */
    private void extract(byte[] tarGz, Path target) throws IOException {
        try (BufferedInputStream bis = new BufferedInputStream(new ByteArrayInputStream(tarGz));
             GzipCompressorInputStream gz = new GzipCompressorInputStream(bis);
             TarArchiveInputStream tar = new TarArchiveInputStream(gz)) {
            TarArchiveEntry e;
            while ((e = tar.getNextTarEntry()) != null) {
                String name = e.getName();
                if (e.isSymbolicLink() || e.isLink()) continue;
                if (name == null || name.startsWith("/") || name.contains("..")
                        || name.contains("\\") || name.contains(":")) continue;
                Path out = target.resolve(name).normalize();
                if (!out.startsWith(target)) continue;
                if (e.isDirectory()) {
                    Files.createDirectories(out);
                    continue;
                }
                if (out.getParent() != null) Files.createDirectories(out.getParent());
                Files.copy(tar, out, StandardCopyOption.REPLACE_EXISTING);
            }
        }
    }

    // ===== utils =====

    private static byte[] readAll(InputStream in, int size) throws IOException {
        byte[] buf = new byte[Math.max(0, size)];
        int off = 0;
        while (off < buf.length) {
            int r = in.read(buf, off, buf.length - off);
            if (r < 0) break;
            off += r;
        }
        if (off == buf.length) return buf;
        byte[] trimmed = new byte[off];
        System.arraycopy(buf, 0, trimmed, 0, off);
        return trimmed;
    }

    /** metadata.yml 内极简提取 pythonPackages（支持 YAML list 和 JSON array 两种写法）. */
    @SuppressWarnings("unchecked")
    static List<String> extractPythonPackages(byte[] metadataBytes) {
        if (metadataBytes == null || metadataBytes.length == 0) return Collections.emptyList();
        String text = new String(metadataBytes, StandardCharsets.UTF_8);
        // 尝试 JSON
        String trimmed = text.trim();
        if (trimmed.startsWith("{")) {
            try {
                Map<String, Object> m = JsonUtil.fromJsonToMap(trimmed);
                Object v = m.get("pythonPackages");
                if (v instanceof List) {
                    List<String> out = new ArrayList<>();
                    for (Object o : (List<Object>) v) out.add(String.valueOf(o));
                    return out;
                }
            } catch (Throwable ignore) { /* fallback to yaml-ish */ }
        }
        // YAML-ish：行首 "- xxx" 紧跟在 "pythonPackages:" 下
        List<String> out = new ArrayList<>();
        boolean inBlock = false;
        for (String raw : text.split("\r?\n")) {
            String line = raw;
            if (!inBlock) {
                String s = line.trim();
                if (s.startsWith("pythonPackages")) {
                    inBlock = true;
                    continue;
                }
            } else {
                String s = line.trim();
                if (s.startsWith("- ")) {
                    out.add(s.substring(2).trim());
                } else if (!s.isEmpty() && !s.startsWith("#")) {
                    break;
                }
            }
        }
        return out;
    }

    /**
     * 剥版本号 + 按 <a href="https://peps.python.org/pep-0503/">PEP 503</a> 归一化包名.
     *
     * <p>PEP 503：包名对比时下划线 {@code _}、点 {@code .}、连字符 {@code -} 都视为等价，
     * 大小写不敏感. 例如下面几种写法在 PyPI 里是同一个包：
     * <pre>
     *   et-xmlfile  ↔  et_xmlfile  ↔  ET_XMLFILE
     *   typing-extensions  ↔  typing_extensions
     *   pdfminer.six  ↔  pdfminer-six
     * </pre>
     *
     * <p>没这步归一化：用户 bundle 声明 {@code et-xmlfile}（PyPI 规范写法），
     * 而镜像白名单是 {@code et_xmlfile}（importlib.metadata 实际 installed name），
     * 字符串对比不等 → 误拒 {@code bundle.pip_not_in_image}.
     */
    static String baseName(String pkgSpec) {
        if (pkgSpec == null) return "";
        int p = indexOfAny(pkgSpec, "=<>~!");
        String name = (p < 0 ? pkgSpec : pkgSpec.substring(0, p)).trim().toLowerCase();
        // PEP 503：连续的 `[._-]+` 统一折叠成单个 `-`
        return name.replaceAll("[._-]+", "-");
    }

    private static int indexOfAny(String s, String any) {
        int best = -1;
        for (int i = 0; i < any.length(); i++) {
            int idx = s.indexOf(any.charAt(i));
            if (idx >= 0 && (best < 0 || idx < best)) best = idx;
        }
        return best;
    }

    private static boolean isEmpty(Path dir) throws IOException {
        try (java.util.stream.Stream<Path> s = Files.list(dir)) {
            return !s.findAny().isPresent();
        }
    }

    private static void deleteRecursive(Path root) throws IOException {
        if (!Files.exists(root)) return;
        try (java.util.stream.Stream<Path> w = Files.walk(root)) {
            w.sorted(Comparator.reverseOrder()).forEach(p -> {
                try { Files.deleteIfExists(p); } catch (IOException ignore) { /* best effort */ }
            });
        }
    }
}
