// @ts-check
import {
    SKYBOX_MAGIC_KEYWORDS,
    buildAutoPolicy,
    getLocalFilesAutoPolicy,
    getWebXRProfilesForMode,
    hasAutoFeatureSelection,
    makeFilesLocalIsEnabled,
    resolveOptions,
    resolveSkyboxSelectionUrls,
    resolveSkyboxValueToUrl,
    shouldHandleUrlInAutoMode,
} from './local-files-analysis.js';

/** @typedef {import('./local-files-types.js').LocalizationContext} LocalizationContext */
/** @typedef {import('./local-files-types.js').LocalizationOptions} LocalizationOptions */
/** @typedef {import('./local-files-types.js').LocalizationStats} LocalizationStats */
/** @typedef {import('./local-files-types.js').AutoPolicy} AutoPolicy */
/** @typedef {import('./local-files-types.js').UrlHandler} UrlHandler */
import {
    Cache,
    downloadBinary,
    downloadText,
    ensureTrailingSlash,
    fixRelativeNewURL,
    getRelativeToBasePath,
    getShortUrlName,
    getValidFilename,
    normalizeWebPath,
    recordFailedDownload,
    replaceAll,
    finishMakeLocalProgress,
} from './local-files-utils.js';
import { needleBlue, needleDim, needleLog, needleSupportsColor } from './logging.js';

const debug = false;

/** @param {unknown} err @returns {string} */
function getErrMessage(err) { return err instanceof Error ? getErrMessage(err) : String(err); }

/** @type {UrlHandler[]} */
const urlHandlers = [
    {
        name: "Google Fonts CSS",
        pattern: /["'`](https:\/\/fonts\.googleapis\.com\/css2?\?[^"'`]+)["'`]/g,
        type: "css",
        feature: "fonts",
    },
    {
        name: "Google Fonts gstatic",
        pattern: /["'`(](https:\/\/fonts\.gstatic\.com\/[^"'`)]+)["'`)]/g,
        type: "binary",
        feature: "fonts",
    },
    {
        name: "QRCode.js",
        pattern: /["'`](https:\/\/cdn\.jsdelivr\.net\/gh\/davidshimjs\/qrcodejs@[^"'`]+\/qrcode\.min\.js)["'`]/g,
        type: "binary",
        feature: "cdn-scripts",
    },
    {
        name: "vConsole",
        pattern: /["'`](https:\/\/cdn\.jsdelivr\.net\/npm\/vconsole@[^"'`]+\/dist\/vconsole\.min\.js)["'`]/g,
        type: "binary",
        feature: "cdn-scripts",
    },
    {
        name: "HLS.js",
        pattern: /["'`](https:\/\/cdn\.jsdelivr\.net\/npm\/hls\.js@[^"'`]+)["'`]/g,
        type: "binary",
        feature: "cdn-scripts",
    },
    {
        name: "WebXR Input Profiles",
        pattern: /["'`](https:\/\/cdn\.jsdelivr\.net\/npm\/@webxr-input-profiles\/assets@[^"'`]*\/dist\/profiles)\/?["'`]/g,
        type: "webxr-profiles",
        feature: "xr",
    },
    {
        name: "Polyhaven",
        pattern: /["'`](https:\/\/dl\.polyhaven\.org\/file\/[^"'`]+)["'`]/g,
        type: "binary",
        feature: "polyhaven",
    },
    {
        name: "Needle CDN skybox",
        pattern: /["'`](https:\/\/cdn\.needle\.tools\/static\/skybox\/[^"'`]+)["'`]/g,
        type: "binary",
        feature: "skybox",
    },
    {
        name: "Needle CDN fonts",
        pattern: /["'`](https:\/\/cdn\.needle\.tools\/static\/fonts\/[^"'`]+)["'`]/g,
        type: "binary",
        feature: "needle-fonts",
    },
    {
        name: "Needle CDN models",
        pattern: /["'`](https:\/\/cdn\.needle\.tools\/static\/models\/[^"'`]+)["'`]/g,
        type: "binary",
        feature: "needle-models",
    },
    {
        name: "Needle CDN avatars",
        pattern: /["'`](https:\/\/cdn\.needle\.tools\/static\/avatars\/[^"'`]+)["'`]/g,
        type: "binary",
        feature: "needle-avatars",
    },
    {
        name: "Needle CDN branding",
        pattern: /["'`](https:\/\/cdn\.needle\.tools\/static\/branding\/[^"'`]+)["'`]/g,
        type: "binary",
        feature: "needle-branding",
    },
    {
        name: "Needle CDN basis decoder",
        pattern: /["'`](https:\/\/cdn\.needle\.tools\/static\/three\/[^"'`]*\/basis2\/?)['"`]/g,
        type: "decoder-dir",
        decoderFiles: ["basis_transcoder.js", "basis_transcoder.wasm"],
        localDirName: "basis",
        feature: "ktx2",
    },
    {
        name: "Draco decoder (gstatic)",
        pattern: /["'`](https:\/\/www\.gstatic\.com\/draco\/versioned\/decoders\/[^"'`]*\/?)['"`]/g,
        type: "decoder-dir",
        decoderFiles: ["draco_decoder.js", "draco_decoder.wasm", "draco_wasm_wrapper.js"],
        localDirName: "draco",
        feature: "draco",
    },
    {
        name: "Needle CDN MaterialX",
        pattern: /["'`](https:\/\/cdn\.needle\.tools\/static\/materialx\/[^"'`]*\/?)['"`]/g,
        type: "decoder-dir",
        decoderFiles: ["JsMaterialXCore.wasm", "JsMaterialXGenShader.wasm", "JsMaterialXGenShader.data.txt"],
        localDirName: "materialx",
        feature: "materialx",
    },
    {
        name: "Needle uploads",
        pattern: /["'`](https:\/\/uploads\.needle\.tools\/include\/[^"'`]+)["'`]/g,
        type: "binary",
        feature: "needle-uploads",
    },
    {
        name: "GitHub raw content",
        pattern: /["'`](https:\/\/raw\.githubusercontent\.com\/[^"'`$]+\.[a-z]{2,5})["'`]/g,
        type: "binary",
        feature: "github-content",
    },
    {
        name: "threejs.org models",
        pattern: /["'`](https:\/\/threejs\.org\/examples\/models\/[^"'`]+)["'`]/g,
        type: "binary",
        feature: "threejs-models",
    },
];

export { makeFilesLocalIsEnabled };

/**
 * @returns {LocalizationStats}
 */
function createLocalizationStats() {
    return {
        fileCount: 0,
        totalBytes: 0,
        mimeCounts: new Map(),
    };
}

/**
 * @param {string} url
 * @param {string} [fallback]
 * @returns {string}
 */
function inferMimeType(url, fallback = "application/octet-stream") {
    const value = String(url || "").split("?")[0].toLowerCase();
    if (value.endsWith(".css")) return "text/css";
    if (value.endsWith(".json")) return "application/json";
    if (value.endsWith(".js") || value.endsWith(".mjs")) return "application/javascript";
    if (value.endsWith(".wasm")) return "application/wasm";
    if (value.endsWith(".ttf")) return "font/ttf";
    if (value.endsWith(".woff2")) return "font/woff2";
    if (value.endsWith(".woff")) return "font/woff";
    if (value.endsWith(".otf")) return "font/otf";
    if (value.endsWith(".png")) return "image/png";
    if (value.endsWith(".jpg") || value.endsWith(".jpeg")) return "image/jpeg";
    if (value.endsWith(".webp")) return "image/webp";
    if (value.endsWith(".exr")) return "image/exr";
    if (value.endsWith(".glb")) return "model/gltf-binary";
    if (value.endsWith(".gltf")) return "model/gltf+json";
    return fallback;
}

/**
 * @param {LocalizationStats} stats
 * @param {string} url
 * @param {number} sizeBytes
 * @param {string | null | undefined} mimeType
 */
function recordLocalizedAsset(stats, url, sizeBytes, mimeType) {
    if (!stats || !Number.isFinite(sizeBytes) || sizeBytes < 0) return;
    stats.fileCount += 1;
    stats.totalBytes += sizeBytes;
    const mime = mimeType || inferMimeType(url);
    stats.mimeCounts.set(mime, (stats.mimeCounts.get(mime) || 0) + 1);
}

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

    const options = resolveOptions(userSettings);

    const startupLines = ["Local files plugin is enabled"];
    if (options.platform) startupLines.push("Platform: " + options.platform);
    if (options.exclude?.length) startupLines.push("Custom excludes: " + options.exclude.join(", "));
    if (options.features === "auto") startupLines.push("Feature detection: auto");
    else if (Array.isArray(options.features) && options.features.length) startupLines.push("Features: " + options.features.join(", "));
    if (options.excludeFeatures?.length) startupLines.push("Excluded features: " + options.excludeFeatures.join(", "));
    if (options.packages?.length) startupLines.push("Package filter: " + options.packages.join(", "));
    const projectDir = process.cwd();
    const autoPolicy = getLocalFilesAutoPolicy(projectDir) ?? buildAutoPolicy(options, projectDir);

    const activeHandlers = getActiveHandlers(options, autoPolicy);
    {
        const featureSet = Array.from(new Set(activeHandlers.map(h => h.feature)));
        const allFeatureSet = Array.from(new Set(urlHandlers.map(h => h.feature)));
        if (options.features === "auto") {
            startupLines.push("Auto-detected features (" + featureSet.length + "/" + allFeatureSet.length + "): " + featureSet.join(", "));
        }
        else if (Array.isArray(options.features) && options.features.length || options.excludeFeatures?.length) {
            startupLines.push("Active features (" + featureSet.length + "): " + featureSet.join(", "));
        }
    }
    needleLog("needle:local-files", startupLines.join("\n"), "log", { dimBody: false });

    const cache = new Cache();
    /** @type {Map<string, string>} */
    const failedDownloads = new Map();
    const localizationStats = createLocalizationStats();

    /** @type {import('vite').ResolvedConfig | null} */
    let viteConfig = null;

    const plugin = /** @type {import('vite').Plugin} */ ({
        name: "needle:local-files",
        apply: "build",
        configResolved(config) {
            viteConfig = config;
        },
        async buildStart() {
            await prefetchConfiguredAssets({
                pluginContext: /** @type {import('rollup').PluginContext} */ (this),
                cache,
                command,
                viteConfig,
                options,
                autoPolicy,
                failedDownloads,
                    localizationStats,
            }, activeHandlers);
        },
        async transform(src, _id) {
            if (options.packages?.length && _id) {
                const matchesPackage = options.packages.some(pkg => _id.includes('/node_modules/' + pkg + '/') || _id.includes('\\node_modules\\' + pkg + '\\'));
                if (!matchesPackage) {
                    const isProjectFile = !_id.includes('/node_modules/') && !_id.includes('\\node_modules\\');
                    if (!isProjectFile) return { code: src, map: null };
                }
            }
            try {
                const assetsDir = normalizeWebPath(ensureTrailingSlash(viteConfig?.build?.assetsDir || "assets"));
                const isCssTransform = /\.css($|\?)/i.test(_id || "");
                const currentDir = isCssTransform ? assetsDir : "";
                src = await makeLocal(src, "ext/", currentDir, {
                    pluginContext: /** @type {import('rollup').PluginContext} */ (this),
                    cache,
                    command,
                    viteConfig,
                    options,
                    autoPolicy,
                    failedDownloads,
                    localizationStats,
                }, activeHandlers);
                src = fixRelativeNewURL(src);
            }
            catch (err) {
                needleLog("needle:local-files", "Error in transform: " + getErrMessage(err), "error");
            }
            return {
                code: src,
                map: null,
            };
        },
        renderChunk(code, chunk) {
            if (!chunk.fileName?.endsWith(".js")) return null;
            const fixed = fixRelativeNewURL(code);
            if (fixed === code) return null;
            return {
                code: fixed,
                map: null,
            };
        },
        generateBundle(_options, bundle) {
            for (const output of Object.values(bundle)) {
                if (output.type !== "chunk") continue;
                if (!output.fileName?.endsWith(".js")) continue;
                const fixed = fixRelativeNewURL(output.code);
                if (fixed !== output.code) output.code = fixed;
            }
        },
        transformIndexHtml: {
            order: 'pre',
            async handler(html, _ctx) {
                try {
                    html = await makeLocalHtml(html, "ext/", {
                        pluginContext: null,
                        cache,
                        command,
                        viteConfig,
                        options,
                        autoPolicy,
                        failedDownloads,
                        localizationStats,
                    }, activeHandlers);
                }
                catch (err) {
                    needleLog("needle:local-files", "Error in transformIndexHtml: " + getErrMessage(err), "error");
                }
                return html;
            }
        },
        buildEnd() {
            finishMakeLocalProgress();
            const map = cache.map;
            const shorten = (value, max = 140) => value.length > max ? `${value.slice(0, Math.max(0, max - 1))}…` : value;
            const supportsColor = needleSupportsColor();
            const key = (text) => supportsColor ? needleBlue(text) : text;
            const localized = ["Local files summary"]; 
            localized.push(key("Files made local") + " : " + localizationStats.fileCount);
            localized.push(key("Total size") + " : " + (localizationStats.totalBytes / 1024).toFixed(2) + " kB");
            localized.push(key("Unique mime types") + " : " + localizationStats.mimeCounts.size);
            if (localizationStats.mimeCounts.size > 0) {
                const mimeBreakdown = Array.from(localizationStats.mimeCounts.entries())
                    .sort((a, b) => b[1] - a[1])
                    .map(([mime, count]) => supportsColor ? `${mime}${needleDim(` ${count}×`)}` : `${mime} ${count}×`)
                    .join(", ");
                localized.push(key("Mime types") + " : " + mimeBreakdown);
            }
            const remoteUrls = Array.from(map.keys()).filter(url => /^https?:\/\//i.test(url));
            const previewLimit = 5;
            needleLog("needle:local-files", localized.join("\n"), "log", { dimBody: false });
            if (remoteUrls.length > 0) {
                needleLog("needle:local-files", remoteUrls.slice(0, previewLimit).map(url => "- " + shorten(url)).join("\n") + (remoteUrls.length > previewLimit ? `\n... and ${remoteUrls.length - previewLimit} more` : ""), "log", { showHeader: false, dimBody: true });
            }
            if (failedDownloads.size > 0) {
                const failed = ["Failed to make local:"];
                for (const [url, message] of Array.from(failedDownloads.entries())) {
                    const shortName = getShortUrlName(url);
                    failed.push("  " + shortName + " (" + url + ")" + (message ? " - " + message : ""));
                }
                needleLog("needle:local-files", failed.join("\n"), "warn");
            }
        }
    });
    return plugin;
}

/**
 * @param {LocalizationOptions} options
 * @param {AutoPolicy | null} autoPolicy
 * @returns {UrlHandler[]}
 */
export function getActiveHandlers(options, autoPolicy) {
    let handlers = urlHandlers;

    const hasAuto = hasAutoFeatureSelection(options.features);

    if (hasAuto && Array.isArray(options.features) && options.features.length) {
        const detected = autoPolicy?.features ?? new Set();
        const includeSet = new Set(options.features.filter(f => f !== "auto"));
        handlers = handlers.filter(h => detected.has(h.feature) || includeSet.has(h.feature));
    }
    else if (hasAuto) {
        const detected = autoPolicy?.features ?? new Set();
        handlers = handlers.filter(h => detected.has(h.feature));
    }
    else if (Array.isArray(options.features) && options.features.length) {
        const includeSet = new Set(options.features.filter(f => f !== "auto"));
        handlers = handlers.filter(h => includeSet.has(h.feature));
    }

    if (options.excludeFeatures?.length) {
        const excludeSet = new Set(options.excludeFeatures);
        handlers = handlers.filter(h => !excludeSet.has(h.feature));
    }

    return handlers;
}

/**
 * @param {string} src
 * @returns {string}
 */
function stripComments(src) {
    let result = '';
    let i = 0;
    const len = src.length;

    while (i < len) {
        const ch = src[i];
        const next = i + 1 < len ? src[i + 1] : '';

        if (ch === '"' || ch === "'" || ch === '`') {
            const quote = ch;
            result += ch;
            i++;
            while (i < len) {
                const c = src[i];
                if (c === '\\') {
                    result += src[i] + (i + 1 < len ? src[i + 1] : '');
                    i += 2;
                    continue;
                }
                if (c === quote) {
                    result += c;
                    i++;
                    break;
                }
                result += c;
                i++;
            }
            continue;
        }

        if (ch === '/' && next === '/') {
            const prev = i > 0 ? src[i - 1] : '';
            if (prev !== ':') {
                while (i < len && src[i] !== '\n') {
                    result += ' ';
                    i++;
                }
                continue;
            }
        }

        if (ch === '/' && next === '*') {
            result += '  ';
            i += 2;
            while (i < len) {
                if (src[i] === '*' && i + 1 < len && src[i + 1] === '/') {
                    result += '  ';
                    i += 2;
                    break;
                }
                result += src[i] === '\n' ? '\n' : ' ';
                i++;
            }
            continue;
        }

        result += ch;
        i++;
    }

    return result;
}

/**
 * @param {string} url
 * @param {LocalizationOptions} options
 * @returns {boolean}
 */
function shouldExclude(url, options) {
    if (url.includes("${")) return true;

    if (options.platform === "facebook-instant") {
        if (url.includes("connect.facebook.net")) return true;
    }

    if (options.exclude) {
        for (const pattern of options.exclude) {
            if (typeof pattern === "string") {
                if (url.includes(pattern)) return true;
            } else if (pattern instanceof RegExp) {
                if (pattern.test(url)) return true;
            }
        }
    }
    return false;
}

/**
 * @param {string} src
 * @param {string} stripped
 * @param {string} basePath
 * @param {string} currentDir
 * @param {LocalizationContext} context
 * @returns {Promise<string>}
 */
async function expandTemplateUrls(src, stripped, basePath, currentDir, context) {
    const expansions = context.options.templateExpansions;
    if (!expansions?.length) return src;

    for (const expansion of expansions) {
        const { cdnPrefix, variables } = expansion;
        if (!cdnPrefix || !variables || !Object.keys(variables).length) continue;

        const localPrefix = expansion.localPrefix || deriveLocalPrefix(cdnPrefix);
        const localBasePath = basePath + localPrefix + "/";

        const escapedPrefix = cdnPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        const templateRegex = new RegExp('`' + escapedPrefix + '([^`]*\\$\\{[^`]*)`', 'g');

        const templateSuffixes = new Set();
        let match;
        while ((match = templateRegex.exec(stripped)) !== null) {
            templateSuffixes.add(match[1]);
        }

        if (templateSuffixes.size === 0) continue;

        for (const suffix of Array.from(templateSuffixes)) {
            const expandedSuffixes = expandVariables(suffix, variables);

            for (const expanded of expandedSuffixes) {
                const concreteUrl = cdnPrefix + expanded;
                const localFilePath = localBasePath + expanded;

                if (context.cache.getFromCache(concreteUrl)) continue;

                try {
                    const data = await downloadBinary(concreteUrl);
                    if (context.command === 'build' && context.pluginContext) {
                        context.pluginContext.emitFile({
                            type: 'asset',
                            fileName: localFilePath,
                            source: data,
                        });
                    }
                    context.cache.addToCache(concreteUrl, localFilePath);
                    if (debug) console.log("[needle:local-files] Template expansion: " + concreteUrl + " → " + localFilePath);
                }
                catch (err) {
                    needleLog("needle:local-files", "Failed to download template expansion: " + concreteUrl + " - " + getErrMessage(err), "warn", { dimBody: false });
                }
            }
        }

        const replaceRegex = new RegExp('`' + escapedPrefix + '([^`]*\\$\\{[^`]*)`', 'g');
        src = src.replace(replaceRegex, (/** @type {string} */ _fullMatch, /** @type {string} */ rest) => {
            const newPrefix = getRelativeToBasePath(localBasePath, currentDir);
            return '`' + newPrefix + rest + '`';
        });
    }

    return src;
}

/**
 * @param {string} cdnPrefix
 * @returns {string}
 */
function deriveLocalPrefix(cdnPrefix) {
    try {
        const url = new URL(cdnPrefix);
        const segments = url.pathname.split('/').filter(Boolean);
        return segments[segments.length - 1] || 'cdn';
    }
    catch {
        return 'cdn';
    }
}

/**
 * @param {string} template
 * @param {Record<string, string[]>} variables
 * @returns {string[]}
 */
function expandVariables(template, variables) {
    const varRefs = /** @type {string[]} */ ([]);
    const varRegex = /\$\{(\w+)\}/g;
    /** @type {RegExpExecArray | null} */
    let m = null;
    while ((m = varRegex.exec(template)) !== null) {
        if (!varRefs.includes(m[1])) {
            varRefs.push(m[1]);
        }
    }

    for (const v of varRefs) {
        if (!variables[v]?.length) return [];
    }

    if (varRefs.length === 0) return [template];

    const results = /** @type {string[]} */ ([]);
    /**
     * @param {number} idx
     * @param {Record<string, string>} current
     */
    function expand(idx, current) {
        if (idx === varRefs.length) {
            let expanded = template;
            for (const [key, value] of Object.entries(current)) {
                expanded = expanded.split('${' + key + '}').join(value);
            }
            results.push(expanded);
            return;
        }
        const varName = varRefs[idx];
        for (const value of variables[varName]) {
            current[varName] = value;
            expand(idx + 1, current);
        }
    }
    expand(0, {});
    return results;
}

/**
 * @param {string} src
 * @param {string} basePath
 * @param {string} currentDir
 * @param {LocalizationContext} context
 * @param {UrlHandler[]} [handlers]
 * @returns {Promise<string>}
 */
export async function makeLocal(src, basePath, currentDir, context, handlers) {
    if (!handlers) handlers = urlHandlers;

    const stripped = stripComments(src);

    for (const handler of handlers) {
        handler.pattern.lastIndex = 0;

        const matches = /** @type {string[]} */ ([]);
        /** @type {RegExpExecArray | null} */
        let match = null;
        while ((match = handler.pattern.exec(stripped)) !== null) {
            const url = match[1];
            if (!url || url.length < 10) continue;
            if (shouldExclude(url, context.options)) continue;
            if (!shouldHandleUrlInAutoMode(url, handler, context)) continue;
            if (url.endsWith("/") && handler.type !== "decoder-dir" && handler.type !== "webxr-profiles") continue;
            matches.push(url);
        }

        if (matches.length === 0) continue;

        const uniqueUrls = Array.from(new Set(matches));

        for (const url of uniqueUrls) {
            try {
                const handlerBasePath = getFeatureBasePath(basePath, handler.feature);
                if (handler.type === "decoder-dir") {
                    const localPath = await handleDecoderDir(url, handlerBasePath, currentDir, handler, context);
                    if (localPath) src = replaceAll(src, url, localPath);
                }
                else if (handler.type === "webxr-profiles") {
                    const localPath = await handleWebXRProfiles(url, handlerBasePath, currentDir, context);
                    if (localPath) src = replaceAll(src, url, localPath);
                }
                else if (handler.type === "css") {
                    const localPath = await handleCssUrl(url, handlerBasePath, currentDir, context, handlers);
                    if (localPath) src = replaceAll(src, url, localPath);
                }
                else {
                    const localPath = await handleBinaryUrl(url, handlerBasePath, currentDir, context);
                    if (localPath) src = replaceAll(src, url, localPath);
                }
            }
            catch (err) {
                recordFailedDownload(context, url, err);
            }
        }
    }

    src = await expandTemplateUrls(src, stripped, basePath, currentDir, context);

    return src;
}

/**
 * @param {string} html
 * @param {string} basePath
 * @param {LocalizationContext} context
 * @param {UrlHandler[]} [handlers]
 * @returns {Promise<string>}
 */
export async function makeLocalHtml(html, basePath, context, handlers) {
    if (!handlers) handlers = urlHandlers;

    for (const handler of handlers) {
        handler.pattern.lastIndex = 0;
        /** @type {RegExpExecArray | null} */
        let match = null;
        const matches = /** @type {string[]} */ ([]);
        while ((match = handler.pattern.exec(html)) !== null) {
            const url = match[1];
            if (!url || url.length < 10) continue;
            if (shouldExclude(url, context.options)) continue;
            if (!shouldHandleUrlInAutoMode(url, handler, context)) continue;
            if (url.endsWith("/") && handler.type !== "decoder-dir" && handler.type !== "webxr-profiles") continue;
            matches.push(url);
        }

        const uniqueUrls = Array.from(new Set(matches));
        for (const url of uniqueUrls) {
            try {
                const handlerBasePath = getFeatureBasePath(basePath, handler.feature);
                if (handler.type === "css") {
                    const localPath = await handleCssUrl(url, handlerBasePath, "", context, handlers);
                    if (localPath) html = replaceAll(html, url, localPath);
                }
                else {
                    const localPath = await handleBinaryUrl(url, handlerBasePath, "", context);
                    if (localPath) html = replaceAll(html, url, localPath);
                }
            }
            catch (err) {
                recordFailedDownload(context, url, err);
                needleLog("needle:local-files", "Failed to make HTML URL local: " + url + " - " + getErrMessage(err), "warn", { dimBody: false });
            }
        }
    }
    return html;
}

/**
 * @param {string} basePath
 * @param {string} feature
 * @returns {string}
 */
export function getFeatureBasePath(basePath, feature) {
    const normalized = basePath.endsWith("/") ? basePath : basePath + "/";
    const mappedFeatureDir = mapFeatureToOutputDir(feature);
    if (normalized.endsWith("/" + mappedFeatureDir + "/")) return normalized;
    return normalized + mappedFeatureDir + "/";
}

/**
 * @param {string} feature
 * @returns {string}
 */
function mapFeatureToOutputDir(feature) {
    if (feature === "cdn-scripts") return "scripts";
    if (feature === "needle-fonts") return "fonts";
    if (feature === "needle-avatars") return "xr/avatars";
    if (feature === "polyhaven") return "skybox";
    return feature;
}

/**
 * @param {string} url
 * @param {string} basePath
 * @param {string} currentDir
 * @param {LocalizationContext} context
 * @param {UrlHandler[]} handlers
 * @returns {Promise<string|undefined>}
 */
async function handleCssUrl(url, basePath, currentDir, context, handlers) {
    const cached = context.cache.getFromCache(url);
    if (typeof cached === 'string') return cached;

    let cssContent = await downloadText(url);
    cssContent = await makeLocal(cssContent, basePath, basePath, context, handlers);
    recordLocalizedAsset(context.localizationStats, url, Buffer.byteLength(cssContent, "utf8"), "text/css");

    const familyNameMatch = /family=([^&]+)/.exec(url);
    const familyName = familyNameMatch ? getValidFilename(familyNameMatch[1], cssContent) : getValidFilename(url, cssContent);
    const fileName = "font-" + familyName + ".css";
    const outputPath = basePath + fileName;

    /** @type {string | undefined} */
    let newPath;
    if (context.command === 'build' && context.pluginContext) {
        const referenceId = context.pluginContext.emitFile({
            type: 'asset',
            fileName: outputPath,
            source: cssContent,
        });
        const localPath = "" + context.pluginContext.getFileName(referenceId);
        newPath = getRelativeToBasePath(localPath, currentDir);
    }
    else {
        const base64 = Buffer.from(cssContent).toString('base64');
        newPath = "data:text/css;base64," + base64;
    }
    if (newPath) context.cache.addToCache(url, newPath);
    return newPath;
}

/**
 * @param {string} url
 * @param {string} basePath
 * @param {string} currentDir
 * @param {LocalizationContext} context
 * @returns {Promise<string|undefined>}
 */
async function handleBinaryUrl(url, basePath, currentDir, context) {
    const cached = context.cache.getFromCache(url);
    if (typeof cached === 'string') return cached;

    const data = await downloadBinary(url);
    recordLocalizedAsset(context.localizationStats, url, data.length, inferMimeType(url));
    const filename = getValidFilename(url, data);

    /** @type {string | undefined} */
    let newPath;
    if (context.command === 'build' && context.pluginContext) {
        const referenceId = context.pluginContext.emitFile({
            type: 'asset',
            fileName: basePath + filename,
            source: data,
        });
        const localPath = "" + context.pluginContext.getFileName(referenceId);
        newPath = getRelativeToBasePath(localPath, currentDir);
    }
    else {
        const base64 = Buffer.from(data).toString('base64');
        newPath = "data:application/octet-stream;base64," + base64;
    }
    if (newPath) context.cache.addToCache(url, newPath);
    return newPath;
}

/**
 * @param {string} baseUrl
 * @param {string} basePath
 * @param {string} currentDir
 * @param {UrlHandler} handler
 * @param {LocalizationContext} context
 * @returns {Promise<string>}
 */
async function handleDecoderDir(baseUrl, basePath, currentDir, handler, context) {
    const cached = context.cache.getFromCache(baseUrl);
    if (typeof cached === 'string') return cached;

    const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
    const localDir = ensureTrailingSlash(basePath);
    const files = handler.decoderFiles || [];

    for (const file of files) {
        const fileUrl = normalizedBaseUrl + file;
        const fileCached = context.cache.getFromCache(fileUrl);
        if (fileCached) continue;

        try {
            const data = await downloadBinary(fileUrl);
            recordLocalizedAsset(context.localizationStats, fileUrl, data.length, inferMimeType(fileUrl));
            if (context.command === 'build' && context.pluginContext) {
                const referenceId = context.pluginContext.emitFile({
                    type: 'asset',
                    fileName: localDir + file,
                    source: data,
                });
                const localPath = "" + context.pluginContext.getFileName(referenceId);
                context.cache.addToCache(fileUrl, localPath);
            }
        }
        catch (err) {
            recordFailedDownload(context, fileUrl, err);
            needleLog("needle:local-files", "Failed to download decoder file: " + fileUrl + " - " + getErrMessage(err), "warn", { dimBody: false });
        }
    }

    const newBasePath = getRelativeToBasePath(localDir, currentDir);
    context.cache.addToCache(baseUrl, newBasePath);
    return newBasePath;
}

/**
 * @param {string} baseUrl
 * @param {string} basePath
 * @param {string} currentDir
 * @param {LocalizationContext} context
 * @returns {Promise<string>}
 */
export async function handleWebXRProfiles(baseUrl, basePath, currentDir, context) {
    const cached = context.cache.getFromCache(baseUrl);
    if (typeof cached === 'string') return cached;

    const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
    const localDir = basePath + "webxr-profiles/";
    const profiles = context.autoPolicy?.selectedWebXRProfiles?.length
        ? context.autoPolicy.selectedWebXRProfiles
        : getWebXRProfilesForMode(context.options.webxr);

    try {
        const profilesListUrl = normalizedBaseUrl + "profilesList.json";
        const profilesList = await downloadBinary(profilesListUrl);
        recordLocalizedAsset(context.localizationStats, profilesListUrl, profilesList.length, "application/json");
        if (context.command === 'build' && context.pluginContext) {
            context.pluginContext.emitFile({
                type: 'asset',
                fileName: localDir + "profilesList.json",
                source: profilesList,
            });
            context.cache.addToCache(profilesListUrl, localDir + "profilesList.json");
        }
    }
    catch (err) {
        recordFailedDownload(context, normalizedBaseUrl + "profilesList.json", err);
        needleLog("needle:local-files", "Failed to download profilesList.json: " + getErrMessage(err), "warn", { dimBody: false });
    }

    for (const profile of profiles) {
        const profileDir = localDir + profile + "/";
        const profileBaseUrl = normalizedBaseUrl + profile + "/";

        const filesToDownload = [
            "profile.json",
            "left.glb",
            "right.glb",
        ];

        for (const file of filesToDownload) {
            const fileUrl = profileBaseUrl + file;
            const fileCached = context.cache.getFromCache(fileUrl);
            if (fileCached) continue;

            try {
                const data = await downloadBinary(fileUrl);
                recordLocalizedAsset(context.localizationStats, fileUrl, data.length, inferMimeType(fileUrl));
                if (context.command === 'build' && context.pluginContext) {
                    context.pluginContext.emitFile({
                        type: 'asset',
                        fileName: profileDir + file,
                        source: data,
                    });
                    context.cache.addToCache(fileUrl, profileDir + file);
                }
            }
            catch (err) {
                recordFailedDownload(context, fileUrl, err);
                if (debug) needleLog("needle:local-files", "Failed to download WebXR profile file: " + fileUrl + " - " + getErrMessage(err), "warn", { dimBody: false });
            }
        }
    }

    const newBasePath = getRelativeToBasePath(localDir, currentDir);
    context.cache.addToCache(baseUrl, newBasePath);
    return newBasePath;
}

/**
 * @param {LocalizationContext} context
 * @param {UrlHandler[]} handlers
 * @returns {Promise<void>}
 */
async function prefetchConfiguredAssets(context, handlers) {
    const skyboxHandler = handlers.find(h => h.feature === "skybox");
    if (!skyboxHandler) return;

    const configuredSkyboxUrls = resolveSkyboxSelectionUrls(context.options.skybox, new Set());
    if (!configuredSkyboxUrls || configuredSkyboxUrls.size === 0) {
        if (context.options.skybox === "all") {
            for (const keyword of SKYBOX_MAGIC_KEYWORDS) {
                const url = resolveSkyboxValueToUrl(keyword);
                if (!url) continue;
                try {
                    await handleBinaryUrl(url, getFeatureBasePath("ext/", "skybox"), "", context);
                }
                catch (err) {
                    recordFailedDownload(context, url, err);
                    if (debug) needleLog("needle:local-files", "Failed to prefetch skybox: " + url + " - " + getErrMessage(err), "warn", { dimBody: false });
                }
            }
        }
        return;
    }

    for (const url of configuredSkyboxUrls) {
        try {
            await handleBinaryUrl(url, getFeatureBasePath("ext/", "skybox"), "", context);
        }
        catch (err) {
            recordFailedDownload(context, url, err);
            if (debug) console.warn("[needle:local-files] Failed to prefetch configured skybox: " + url + " - " + getErrMessage(err));
        }
    }
}
