// @ts-check
import { existsSync, readFileSync, readdirSync, openSync, readSync, closeSync } from 'fs';
import { join, relative } from 'path';
import { needleBlue, needleDim, needleLog, needleSupportsColor } from './logging.js';

/** @typedef {import('./local-files-types.js').LocalizationOptions} LocalizationOptions */
/** @typedef {import('./local-files-types.js').AutoPolicy} AutoPolicy */
/** @typedef {import('./local-files-types.js').SceneAnalysisReport} SceneAnalysisReport */
/** @typedef {import('./local-files-types.js').SceneFile} SceneFile */
/** @typedef {import('./local-files-types.js').UrlHandler} UrlHandler */
/** @typedef {import('./local-files-types.js').LocalizationContext} LocalizationContext */

const NEEDLE_COMPONENTS_EXTENSION = "NEEDLE_components";
export const SKYBOX_MAGIC_KEYWORDS = ["studio", "blurred-skybox", "quicklook", "quicklook-ar"];
const SKYBOX_BASE_URL = "https://cdn.needle.tools/static/skybox/";
const HLS_CDN_SEGMENT = "/npm/hls.js@";

/** @type {Map<string, {autoPolicy: AutoPolicy|null, report: SceneAnalysisReport, hasLogged: boolean}>} */
const analysisByProject = new Map();

/**
 * @param {"build" | "serve"} command
 * @param {unknown} _config
 * @param {import('../types').userSettings} userSettings
 * @returns {import('vite').Plugin | null}
 */
export function needleLocalFilesSceneAnalysis(command, _config, userSettings) {
    if (!makeFilesLocalIsEnabled(userSettings)) return null;

    const options = resolveOptions(userSettings);
    let pluginRoot = process.cwd();
    let pluginCommand = command;

    return {
        name: "needle:local-files-scene-analysis",
        enforce: "pre",
        /** @param {import('vite').ResolvedConfig} config */
        configResolved(config) {
            pluginRoot = config?.root || process.cwd();
            pluginCommand = config?.command || command;
        },
        /** @param {import('vite').ViteDevServer} server */
        configureServer(server) {
            const projectDir = server?.config?.root || pluginRoot || process.cwd();
            ensureProjectAnalysis(projectDir, options);
            logSceneAnalysisReport(projectDir, "serve");
            server?.httpServer?.once("close", () => {
                logSceneAnalysisReport(projectDir, "serve");
                analysisByProject.delete(projectDir);
            });
        },
        buildStart() {
            const projectDir = pluginRoot || process.cwd();
            ensureProjectAnalysis(projectDir, options);
        },
        buildEnd() {
            const projectDir = pluginRoot || process.cwd();
            logSceneAnalysisReport(projectDir, "build");
            analysisByProject.delete(projectDir);
        },
        closeBundle() {
            const activeCommand = pluginCommand || command;
            if (activeCommand !== "serve") return;
            const projectDir = pluginRoot || process.cwd();
            logSceneAnalysisReport(projectDir, "serve");
            analysisByProject.delete(projectDir);
        }
    };
}

/**
 * @param {string} projectDir
 * @returns {AutoPolicy | null}
 */
export function getLocalFilesAutoPolicy(projectDir) {
    const entry = analysisByProject.get(projectDir);
    return entry?.autoPolicy ?? null;
}

/**
 * @param {import('../types').userSettings | undefined | null} userSettings
 * @returns {boolean}
 */
export function makeFilesLocalIsEnabled(userSettings) {
    if (typeof userSettings?.makeFilesLocal === "object") return userSettings?.makeFilesLocal?.enabled === true;
    if (userSettings?.makeFilesLocal === "auto") return true;
    return userSettings?.makeFilesLocal === true;
}

/**
 * @param {import('../types').userSettings | undefined | null} userSettings
 * @returns {LocalizationOptions}
 */
export function resolveOptions(userSettings) {
    if (typeof userSettings?.makeFilesLocal === "object") {
        const raw = userSettings.makeFilesLocal;
        const opts = /** @type {LocalizationOptions} */ ({ ...raw });
        if (!opts.exclude?.length && opts.excludeUrls?.length) {
            opts.exclude = opts.excludeUrls;
        }
        return opts;
    }
    if (userSettings?.makeFilesLocal === "auto") {
        return { features: "auto" };
    }
    return {};
}

/**
 * @param {string | string[] | undefined} features
 * @returns {boolean}
 */
export function hasAutoFeatureSelection(features) {
    if (features === "auto") return true;
    if (Array.isArray(features)) return features.includes("auto");
    return false;
}

/**
 * @param {LocalizationOptions} options
 * @param {string} projectDir
 * @returns {AutoPolicy | null}
 */
export function buildAutoPolicy(options, projectDir) {
    if (!hasAutoFeatureSelection(options.features)) return null;
    return detectAutoPolicy(projectDir, options);
}

/**
 * @param {string} projectDir
 * @param {LocalizationOptions} options
 * @returns {{ autoPolicy: AutoPolicy|null, report: SceneAnalysisReport, hasLogged: boolean }}
 */
function ensureProjectAnalysis(projectDir, options) {
    const existing = analysisByProject.get(projectDir);
    if (existing) return existing;

    const report = analyzeProjectGlbs(projectDir);
    const autoPolicy = hasAutoFeatureSelection(options.features)
        ? detectAutoPolicy(projectDir, options, report)
        : null;

    const entry = {
        autoPolicy,
        report,
        hasLogged: false,
    };
    analysisByProject.set(projectDir, entry);
    return entry;
}

/**
 * @param {string} projectDir
 * @param {string} mode
 */
function logSceneAnalysisReport(projectDir, mode) {
    const entry = analysisByProject.get(projectDir);
    if (!entry || entry.hasLogged) return;

    const message = formatSceneAnalysisReport(entry.report, entry.autoPolicy, projectDir, mode);
    needleLog("needle:local-files", message, "log", { dimBody: false });
    entry.hasLogged = true;
}

/**
 * @param {string} projectDir
 * @param {LocalizationOptions} options
 * @param {SceneAnalysisReport | null} [cachedAnalysis]
 * @returns {AutoPolicy}
 */
export function detectAutoPolicy(projectDir, options, cachedAnalysis = null) {
    const features = /** @type {Set<string>} */ (new Set());

    features.add("draco");
    features.add("ktx2");
    features.add("fonts");
    features.add("cdn-scripts");
    features.add("needle-uploads");
    features.add("needle-fonts");
    features.add("needle-branding");

    const pkgJsonPath = join(projectDir, "package.json");
    /** @type {{dependencies?: Record<string,string>, devDependencies?: Record<string,string>} | null} */
    let pkgJson = null;
    try {
        if (existsSync(pkgJsonPath)) {
            pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
        }
    }
    catch (_e) { }

    if (pkgJson) {
        const allDeps = { ...pkgJson.dependencies, ...pkgJson.devDependencies };
        if (allDeps["@needle-tools/materialx"]) {
            features.add("materialx");
        }
    }

    const indexHtmlPath = join(projectDir, "index.html");
    let indexHtml = "";
    try {
        if (existsSync(indexHtmlPath)) {
            indexHtml = readFileSync(indexHtmlPath, "utf-8");
        }
    }
    catch (_e) { }

    const indexSkyboxUrls = collectIndexHtmlSkyboxUrls(indexHtml);
    const selectedSkyboxUrls = resolveSkyboxSelectionUrls(options.skybox, indexSkyboxUrls);
    if (options.skybox === "all") {
        features.add("skybox");
    }
    if (selectedSkyboxUrls?.size) {
        features.add("skybox");
    }

    const srcDir = join(projectDir, "src");
    let srcContent = "";
    try {
        srcContent = collectSourceFiles(srcDir);
    }
    catch (_e) { }

    const registerTypesPath = join(projectDir, "src", "generated", "register_types.ts");
    let registerTypes = "";
    try {
        if (existsSync(registerTypesPath)) {
            registerTypes = readFileSync(registerTypesPath, "utf-8");
        }
    }
    catch (_e) { }

    const allSrc = srcContent + "\n" + registerTypes + "\n" + indexHtml;
    const sceneAnalysis = cachedAnalysis || analyzeProjectGlbs(projectDir);
    const hasWebXRComponent = sceneAnalysis.hasWebXRComponent;
    const hasVideoPlayerComponent = sceneAnalysis.hasVideoPlayerComponent;

    if (hasWebXRComponent) {
        features.add("xr");
    }

    if (/polyhaven\.org/i.test(allSrc)) {
        features.add("polyhaven");
    }

    if (/raw\.githubusercontent\.com/i.test(allSrc)) {
        features.add("github-content");
    }

    if (/threejs\.org\/examples\/models/i.test(allSrc)) {
        features.add("threejs-models");
    }

    if (/cdn\.needle\.tools\/static\/models/i.test(allSrc)) {
        features.add("needle-models");
    }

    if (/cdn\.needle\.tools\/static\/avatars/i.test(allSrc) || /\bAvatarModel\b|\bAvatarLoader\b/i.test(allSrc) || hasWebXRComponent) {
        features.add("needle-avatars");
    }

    if (/materialx|MaterialXLoader/i.test(allSrc)) {
        features.add("materialx");
    }
    if (Array.from(sceneAnalysis.extensions).some(ext => /materialx/i.test(ext))) {
        features.add("materialx");
    }

    if (selectedSkyboxUrls && selectedSkyboxUrls.size > 0) {
        features.add("skybox");
    }

    return {
        features,
        hasWebXR: hasWebXRComponent,
        hasVideoPlayer: hasVideoPlayerComponent,
        allowedSkyboxUrls: selectedSkyboxUrls,
        selectedWebXRProfiles: getWebXRProfilesForMode(options.webxr),
    };
}

/**
 * @param {string} projectDir
 * @returns {SceneAnalysisReport}
 */
export function analyzeProjectGlbs(projectDir) {
    const sceneFiles = collectAssetSceneFiles(projectDir);

    const extensions = /** @type {Set<string>} */ (new Set());
    const componentTypes = /** @type {Set<string>} */ (new Set());
    const componentCounts = /** @type {Map<string, number>} */ (new Map());
    const needleExtensionBlobs = /** @type {string[]} */ ([]);

    let hasWebXRComponent = false;
    let hasVideoPlayerComponent = false;

    let totalNodeCount = 0;
    let totalVertexCount = 0;
    let totalTextureCount = 0;
    let totalMeshCount = 0;
    let totalPrimitiveCount = 0;
    let dracoPrimitiveCount = 0;
    let meshoptBufferViewCount = 0;

    for (const file of sceneFiles) {
        try {
            const json = /** @type {Record<string,any>|null} */ (file.type === "glb"
                ? readGlbJsonChunk(file.path)
                : readGltfJsonFile(file.path));
            if (!json || typeof json !== "object") continue;

            const used = Array.isArray(json.extensionsUsed) ? json.extensionsUsed : [];
            for (const ext of used) {
                if (typeof ext === "string") extensions.add(ext);
            }
            if (json.extensions && typeof json.extensions === "object") {
                for (const ext of Object.keys(json.extensions)) {
                    extensions.add(ext);
                }
            }

            totalNodeCount += Array.isArray(json.nodes) ? json.nodes.length : 0;
            totalTextureCount += Array.isArray(json.textures) ? json.textures.length : 0;

            if (Array.isArray(json.bufferViews)) {
                for (const view of json.bufferViews) {
                    const hasMeshopt = !!view?.extensions?.EXT_meshopt_compression;
                    if (hasMeshopt) meshoptBufferViewCount++;
                }
            }

            const accessors = Array.isArray(json.accessors) ? json.accessors : [];
            const meshes = Array.isArray(json.meshes) ? json.meshes : [];
            totalMeshCount += meshes.length;

            for (const mesh of meshes) {
                const primitives = Array.isArray(mesh?.primitives) ? mesh.primitives : [];
                totalPrimitiveCount += primitives.length;

                for (const primitive of primitives) {
                    const hasDraco = !!primitive?.extensions?.KHR_draco_mesh_compression;
                    if (hasDraco) dracoPrimitiveCount++;

                    const positionAccessorIndex = getPrimitivePositionAccessorIndex(primitive);
                    if (positionAccessorIndex < 0) continue;

                    const accessor = accessors[positionAccessorIndex];
                    if (!accessor || typeof accessor.count !== "number") continue;
                    totalVertexCount += accessor.count;
                }
            }

            const componentNames = collectNeedleComponentNames(json);
            needleExtensionBlobs.push(...collectNeedleComponentExtensionBlobs(json));
            for (const component of componentNames) {
                componentTypes.add(component);
                componentCounts.set(component, (componentCounts.get(component) || 0) + 1);
                if (!hasWebXRComponent && /\bWebXR\b|\bXRRig\b|\bWebARSessionRoot\b/i.test(component)) {
                    hasWebXRComponent = true;
                }
                if (!hasVideoPlayerComponent && /\bVideoPlayer\b/i.test(component)) {
                    hasVideoPlayerComponent = true;
                }
            }

            if ((!hasWebXRComponent || !hasVideoPlayerComponent) && componentNames.length > 0) {
                const blob = componentNames.join(" ");
                if (!hasWebXRComponent && /\bWebXR\b|\bXRRig\b|\bWebARSessionRoot\b/i.test(blob)) {
                    hasWebXRComponent = true;
                }
                if (!hasVideoPlayerComponent && /\bVideoPlayer\b/i.test(blob)) {
                    hasVideoPlayerComponent = true;
                }
            }
        }
        catch (_e) {
        }
    }

    if (!hasWebXRComponent || !hasVideoPlayerComponent) {
        for (const blob of needleExtensionBlobs) {
            if (!hasWebXRComponent && /\bWebXR\b|\bXRRig\b|\bWebARSessionRoot\b/i.test(blob)) {
                hasWebXRComponent = true;
            }
            if (!hasVideoPlayerComponent && /\bVideoPlayer\b/i.test(blob)) {
                hasVideoPlayerComponent = true;
            }
            if (hasWebXRComponent && hasVideoPlayerComponent) break;
        }
    }

    return {
        hasWebXRComponent,
        hasVideoPlayerComponent,
        extensions,
        componentTypes,
        componentCounts,
        glbFileCount: sceneFiles.filter(f => f.type === "glb").length,
        gltfFileCount: sceneFiles.filter(f => f.type === "gltf").length,
        totalSceneFileCount: sceneFiles.length,
        totalNodeCount,
        totalVertexCount,
        totalTextureCount,
        totalMeshCount,
        totalPrimitiveCount,
        dracoPrimitiveCount,
        meshoptBufferViewCount,
    };
}

/**
 * @param {Record<string, any> | null | undefined} primitive
 * @returns {number}
 */
function getPrimitivePositionAccessorIndex(primitive) {
    const direct = primitive?.attributes?.POSITION;
    if (typeof direct === "number") return direct;

    const draco = primitive?.extensions?.KHR_draco_mesh_compression?.attributes?.POSITION;
    if (typeof draco === "number") return draco;

    const fallback = firstNumericValue(primitive?.attributes);
    if (fallback >= 0) return fallback;

    const dracoFallback = firstNumericValue(primitive?.extensions?.KHR_draco_mesh_compression?.attributes);
    if (dracoFallback >= 0) return dracoFallback;

    return -1;
}

/**
 * @param {Record<string, unknown> | null | undefined} obj
 * @returns {number}
 */
function firstNumericValue(obj) {
    if (!obj || typeof obj !== "object") return -1;
    const values = Object.values(obj);
    for (const value of values) {
        if (typeof value === "number") return value;
    }
    return -1;
}

/**
 * @param {unknown} json
 * @returns {string[]}
 */
function collectNeedleComponentNames(json) {
    const names = new Set();

    /** @param {unknown} node */
    function visit(node) {
        if (!node || typeof node !== "object") return;
        if (Array.isArray(node)) {
            for (const item of node) visit(item);
            return;
        }

        for (const [key, value] of Object.entries(node)) {
            if (key === NEEDLE_COMPONENTS_EXTENSION && value) {
                collectComponentNamesFromNeedleExtension(value, names);
            }
            visit(value);
        }
    }

    visit(json);
    return Array.from(names);
}

/**
 * @param {import('./local-files-types.js').NeedleComponentExtension | null | undefined} value
 * @param {Set<string>} names
 */
function collectComponentNamesFromNeedleExtension(value, names) {
    const builtinComponents = value?.builtin_components ?? [];

    for (const entry of builtinComponents) {
        const candidateName = getBuiltinComponentName(entry);
        if (candidateName) names.add(candidateName);
    }
}

/**
 * @param {import('./local-files-types.js').NeedleComponentEntry | null | undefined} node
 * @returns {string}
 */
function getBuiltinComponentName(node) {
    if (!node || typeof node !== "object") return "";
    if (typeof node.name === "string" && node.name.trim()) return node.name.trim();
    return "";
}

/**
 * @param {SceneAnalysisReport} report
 * @param {AutoPolicy | null} autoPolicy
 * @param {string} projectDir
 * @param {string} _mode
 * @returns {string}
 */
function formatSceneAnalysisReport(report, autoPolicy, projectDir, _mode) {
    const supportsColor = needleSupportsColor();
    const projectName = relative(process.cwd(), projectDir) || ".";
    const componentList = report.componentCounts?.size > 0
        ? Array.from(report.componentCounts.entries())
            .sort((a, b) => String(a[0]).localeCompare(String(b[0])))
            .map(([name, count]) => {
                const label = String(name);
                if (!count || count <= 1) return label;
                const suffix = ` ${count}×`;
                return supportsColor ? `${label}${needleDim(suffix)}` : `${label}${suffix}`;
            })
            .join(", ")
        : "(none)";
    const extensionList = report.extensions.size > 0
        ? Array.from(report.extensions).sort((a, b) => a.localeCompare(b)).join(", ")
        : "(none)";
    const autoFeatures = autoPolicy?.features?.size
        ? Array.from(autoPolicy.features).sort((a, b) => a.localeCompare(b)).join(", ")
        : "(n/a)";

    const key = (text) => supportsColor ? needleBlue(text) : text;

    const lines = [
        "Scene analysis report",
        key("Project") + " : " + projectName,
        key("Scene files") + " : " + report.totalSceneFileCount + " (" + report.glbFileCount + " .glb, " + report.gltfFileCount + " .gltf)",
        key("Nodes") + " : " + report.totalNodeCount,
        key("Meshes / Primitives") + " : " + report.totalMeshCount + " / " + report.totalPrimitiveCount,
        key("Vertices") + " : " + report.totalVertexCount,
        key("Textures") + " : " + report.totalTextureCount,
        key("Compression usage") + " : Draco primitives=" + report.dracoPrimitiveCount + ", Meshopt bufferViews=" + report.meshoptBufferViewCount,
        key("Components") + " : " + componentList,
        key("Extensions") + " : " + extensionList,
        key("Detected Features") + " : " + autoFeatures,
    ];

    return lines.join("\n");
}

/**
 * @param {string} projectDir
 * @returns {string[]}
 */
export function collectAssetGlbs(projectDir) {
    const files = collectAssetSceneFiles(projectDir);
    return files.filter(file => file.type === "glb").map(file => file.path);
}

/**
 * @param {string} projectDir
 * @returns {SceneFile[]}
 */
function collectAssetSceneFiles(projectDir) {
    const assetsDir = join(projectDir, "assets");
    if (!existsSync(assetsDir)) return [];

    /** @type {SceneFile[]} */
    const out = [];

    /** @param {string} dir */
    function walk(dir) {
        const entries = readdirSync(dir, { withFileTypes: true });
        for (const entry of entries) {
            const fullPath = join(dir, entry.name);
            if (entry.isDirectory()) {
                walk(fullPath);
                continue;
            }

            if (/^image_\d+_.*\.glb$/i.test(entry.name)) continue;
            if (/^mesh_lod_\d+_.*\.glb$/i.test(entry.name)) continue;

            if (/\.glb$/i.test(entry.name)) {
                out.push({ path: fullPath, type: "glb" });
                continue;
            }
            if (/\.gltf$/i.test(entry.name)) {
                out.push({ path: fullPath, type: "gltf" });
                continue;
            }
        }
    }

    walk(assetsDir);
    return out;
}

/**
 * @param {string} filePath
 * @returns {unknown}
 */
function readGltfJsonFile(filePath) {
    const text = readFileSync(filePath, "utf8");
    return JSON.parse(text);
}

/**
 * @param {string} filePath
 * @returns {Record<string, unknown>}
 */
export function readGlbJsonChunk(filePath) {
    const fd = openSync(filePath, "r");
    try {
        const header = Buffer.allocUnsafe(20);
        const bytesRead = readSync(fd, header, 0, header.length, 0);
        if (bytesRead < 20) throw new Error("Invalid GLB header: " + filePath);

        const magic = header.readUInt32LE(0);
        const version = header.readUInt32LE(4);
        const chunkLength = header.readUInt32LE(12);
        const chunkType = header.readUInt32LE(16);

        if (magic !== 0x46546c67 || version !== 2) throw new Error("Not a GLB v2: " + filePath);
        if (chunkType !== 0x4E4F534A) throw new Error("First GLB chunk is not JSON: " + filePath);

        const jsonBuffer = Buffer.allocUnsafe(chunkLength);
        const jsonBytesRead = readSync(fd, jsonBuffer, 0, chunkLength, 20);
        if (jsonBytesRead < chunkLength) throw new Error("Failed to read GLB JSON chunk: " + filePath);

        const jsonText = jsonBuffer.toString("utf8").replace(/\u0000+$/g, "");
        return JSON.parse(jsonText);
    }
    finally {
        closeSync(fd);
    }
}

/**
 * @param {unknown} json
 * @returns {string[]}
 */
export function collectNeedleComponentExtensionBlobs(json) {
    const blobs = /** @type {string[]} */ ([]);

    /** @param {unknown} node */
    function visit(node) {
        if (!node || typeof node !== "object") return;
        if (Array.isArray(node)) {
            for (const item of node) visit(item);
            return;
        }

        for (const [key, value] of Object.entries(node)) {
            if (key === NEEDLE_COMPONENTS_EXTENSION && value) {
                try {
                    blobs.push(JSON.stringify(value));
                }
                catch (_e) { }
            }
            visit(value);
        }
    }

    visit(json);
    return blobs;
}

/**
 * @param {string} indexHtml
 * @returns {Set<string>}
 */
export function collectIndexHtmlSkyboxUrls(indexHtml) {
    const urls = new Set();
    if (!indexHtml) return urls;

    const attrRegex = /\b(background-image|environment-image)\s*=\s*["']([^"']+)["']/gi;
    let match;
    while ((match = attrRegex.exec(indexHtml)) !== null) {
        const value = (match[2] || "").trim();
        const resolved = resolveSkyboxValueToUrl(value);
        if (resolved) urls.add(resolved);
    }
    return urls;
}

/**
 * @param {string | string[] | undefined | null} skyboxOption
 * @param {Set<string>} indexSkyboxUrls
 * @returns {Set<string> | null}
 */
export function resolveSkyboxSelectionUrls(skyboxOption, indexSkyboxUrls) {
    if (skyboxOption === "all") return null;

    if (Array.isArray(skyboxOption)) {
        const urls = new Set();
        for (const entry of skyboxOption) {
            const resolved = resolveSkyboxValueToUrl(entry);
            if (resolved) urls.add(resolved);
        }
        return urls;
    }

    return indexSkyboxUrls;
}

/**
 * @param {string | null | undefined} value
 * @returns {string | null}
 */
export function resolveSkyboxValueToUrl(value) {
    if (!value) return null;
    if (/^https?:\/\//i.test(value)) return value;

    const normalized = value.replace(/^\/+/, "");
    if (/\.ktx2$/i.test(normalized)) return SKYBOX_BASE_URL + normalized;

    if (/^[a-z0-9\-]+$/i.test(normalized)) {
        return SKYBOX_BASE_URL + normalized + ".ktx2";
    }
    return null;
}

/**
 * @param {string | undefined} mode
 * @returns {string[]}
 */
export function getWebXRProfilesForMode(mode) {
    const allProfiles = [
        "generic-hand",
        "generic-trigger",
        "oculus-touch-v2",
        "oculus-touch-v3",
        "meta-quest-touch-pro",
        "pico-4",
        "pico-neo3",
    ];

    if (mode === "minimal") return ["generic-hand", "generic-trigger"];
    if (mode === "quest") return ["generic-hand", "generic-trigger", "oculus-touch-v2", "oculus-touch-v3", "meta-quest-touch-pro"];
    if (mode === "pico") return ["generic-hand", "generic-trigger", "pico-4", "pico-neo3"];
    return allProfiles;
}

/**
 * @param {string} dir
 * @param {number} [depth]
 * @returns {string}
 */
function collectSourceFiles(dir, depth = 0) {
    if (depth > 5) return "";
    if (!existsSync(dir)) return "";

    let content = "";
    const entries = readdirSync(dir, { withFileTypes: true });

    for (const entry of entries) {
        if (entry.name === "node_modules" || entry.name === ".git") continue;

        const fullPath = join(dir, entry.name);
        if (entry.isDirectory()) {
            content += collectSourceFiles(fullPath, depth + 1);
        }
        else if (/\.(ts|js|tsx|jsx|html|vue|svelte)$/i.test(entry.name)) {
            try {
                content += readFileSync(fullPath, "utf-8") + "\n";
            }
            catch (_e) { }
        }
    }
    return content;
}

/**
 * @param {string} url
 * @param {UrlHandler} handler
 * @param {LocalizationContext} context
 * @returns {boolean}
 */
export function shouldHandleUrlInAutoMode(url, handler, context) {
    if (!hasAutoFeatureSelection(context.options.features) || !context.autoPolicy) return true;

    if (handler.feature === "cdn-scripts" && url.includes(HLS_CDN_SEGMENT)) {
        return context.autoPolicy.hasVideoPlayer;
    }

    if (handler.feature === "needle-avatars" && /\/static\/avatars\/default/i.test(url) && !context.autoPolicy.hasWebXR) {
        return false;
    }

    if (handler.feature === "skybox") {
        const allowed = context.autoPolicy.allowedSkyboxUrls;
        if (!allowed) return true;
        return allowed.has(url);
    }

    return true;
}
