import http from "http";
import {createReadStream, createWriteStream, existsSync} from "fs";
import {promises as fs} from "fs";
import path from "path";
import {fileURLToPath} from "url";
import crypto from "crypto";
import {pipeline} from "stream/promises";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

function parseBooleanEnv(value, fallback = false) {
    if (value === undefined || value === null || value === "") {
        return fallback;
    }
    return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
}

const DATA_ROOT = process.env.NODEDC_BIM_DATA_ROOT
    ? path.resolve(process.env.NODEDC_BIM_DATA_ROOT)
    : path.join(__dirname, "data");
const DATA_DIR = path.join(DATA_ROOT, "projects");
const UPLOADS_DIR = path.join(DATA_ROOT, "uploads");
const SHARES_DIR = path.join(DATA_ROOT, "shares");
const MODELS_DIR = path.join(DATA_ROOT, "models");
const COMMENTS_DIR = path.join(DATA_ROOT, "comments");
const INDEX_FILE = path.join(DATA_DIR, "index.json");
const SHARES_INDEX_FILE = path.join(SHARES_DIR, "index.json");
const MODEL_REGISTRY_FILE = path.join(MODELS_DIR, "registry.json");
const COMMENTS_INDEX_FILE = path.join(COMMENTS_DIR, "index.json");
const FRONTEND_DIST = path.join(__dirname, "..", "frontend", "dist");
const FRONTEND_ROOT = path.join(__dirname, "..", "frontend");
const PORT = parseInt(process.env.PORT, 10) || 8080;
const BIM_SERVICE_SLUG = process.env.NODEDC_BIM_SERVICE_SLUG || "bim-viewer";
const BIM_AUTH_REQUIRED = parseBooleanEnv(
    process.env.NODEDC_BIM_AUTH_REQUIRED ?? process.env.NODEDC_BIM_AUTH_ENABLED,
    false
);
const BIM_PUBLIC_BASE_URL = (process.env.NODEDC_BIM_PUBLIC_URL || process.env.BIM_VIEWER_BASE_URL || "").replace(/\/$/, "");
const LAUNCHER_BASE_URL = (
    process.env.NODEDC_LAUNCHER_BASE_URL ||
    process.env.LAUNCHER_BASE_URL ||
    "http://localhost:5173"
).replace(/\/$/, "");
const LAUNCHER_INTERNAL_BASE_URL = (
    process.env.NODEDC_LAUNCHER_INTERNAL_URL ||
    process.env.NODEDC_LAUNCHER_BASE_URL ||
    process.env.LAUNCHER_BASE_URL ||
    "http://localhost:5173"
).replace(/\/$/, "");
const INTERNAL_ACCESS_TOKEN =
    process.env.NODEDC_INTERNAL_ACCESS_TOKEN ||
    process.env.NODEDC_PLATFORM_SERVICE_TOKEN ||
    "";
const BIM_SESSION_COOKIE = process.env.NODEDC_BIM_SESSION_COOKIE || "nodedc_bim_session";
const BIM_SESSION_TTL_MS = Number(process.env.NODEDC_BIM_SESSION_TTL_MS || 12 * 60 * 60 * 1000);
const BIM_COOKIE_SECURE = parseBooleanEnv(process.env.NODEDC_BIM_COOKIE_SECURE, false);
const BIM_COOKIE_SAMESITE = process.env.NODEDC_BIM_COOKIE_SAMESITE || (BIM_COOKIE_SECURE ? "None" : "Lax");
const CORS_ALLOWED_ORIGINS = String(process.env.NODEDC_BIM_ALLOWED_ORIGINS || "")
    .split(",")
    .map((origin) => origin.trim())
    .filter(Boolean);
const bimSessions = new Map();

const MIME_TYPES = {
    ".html": "text/html",
    ".js": "text/javascript",
    ".mjs": "text/javascript",
    ".css": "text/css",
    ".json": "application/json",
    ".svg": "image/svg+xml",
    ".png": "image/png",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".gif": "image/gif",
    ".ico": "image/x-icon",
    ".glb": "model/gltf-binary",
    ".gltf": "model/gltf+json",
    ".xkt": "application/octet-stream",
    ".las": "application/octet-stream",
    ".laz": "application/octet-stream",
    ".bim": "application/octet-stream",
    ".step": "application/octet-stream",
    ".stp": "application/octet-stream",
    ".wasm": "application/wasm",
    ".txt": "text/plain"
};

const CONVERTIBLE_MODEL_FORMATS = new Map([
    [".step", "step"],
    [".stp", "step"]
]);

const nowIso = () => new Date().toISOString();

let modelRegistryMutationQueue = Promise.resolve();

const ensureStorage = async () => {
    await fs.mkdir(DATA_DIR, {recursive: true});
    await fs.mkdir(UPLOADS_DIR, {recursive: true});
    await fs.mkdir(SHARES_DIR, {recursive: true});
    await fs.mkdir(MODELS_DIR, {recursive: true});
    await fs.mkdir(COMMENTS_DIR, {recursive: true});
    try {
        await fs.access(INDEX_FILE);
    } catch (err) {
        await fs.writeFile(INDEX_FILE, "[]", "utf8");
    }
    try {
        await fs.access(SHARES_INDEX_FILE);
    } catch (err) {
        await fs.writeFile(SHARES_INDEX_FILE, "[]", "utf8");
    }
    try {
        await fs.access(MODEL_REGISTRY_FILE);
    } catch (err) {
        await fs.writeFile(MODEL_REGISTRY_FILE, JSON.stringify(createEmptyModelRegistry(), null, 2), "utf8");
    }
    try {
        await fs.access(COMMENTS_INDEX_FILE);
    } catch (err) {
        await fs.writeFile(COMMENTS_INDEX_FILE, "[]", "utf8");
    }
};

const readIndex = async () => {
    try {
        const raw = await fs.readFile(INDEX_FILE, "utf8");
        const parsed = JSON.parse(raw);
        return Array.isArray(parsed) ? parsed : [];
    } catch (err) {
        console.error("[server] failed to read index.json", err);
        return [];
    }
};

const writeIndex = async (items) => {
    await fs.writeFile(INDEX_FILE, JSON.stringify(items, null, 2), "utf8");
};

const readSharesIndex = async () => {
    try {
        const raw = await fs.readFile(SHARES_INDEX_FILE, "utf8");
        const parsed = JSON.parse(raw);
        return Array.isArray(parsed) ? parsed : [];
    } catch (err) {
        console.error("[server] failed to read shares index.json", err);
        return [];
    }
};

const writeSharesIndex = async (items) => {
    await fs.writeFile(SHARES_INDEX_FILE, JSON.stringify(items, null, 2), "utf8");
};

const readCommentsIndex = async () => {
    try {
        const raw = await fs.readFile(COMMENTS_INDEX_FILE, "utf8");
        const parsed = JSON.parse(raw);
        return Array.isArray(parsed) ? parsed : [];
    } catch (err) {
        if (err?.code === "ENOENT") {
            await fs.mkdir(COMMENTS_DIR, {recursive: true});
            await fs.writeFile(COMMENTS_INDEX_FILE, "[]", "utf8");
            return [];
        }
        console.error("[server] failed to read comments index.json", err);
        return [];
    }
};

const writeCommentsIndex = async (items) => {
    await fs.mkdir(COMMENTS_DIR, {recursive: true});
    await fs.writeFile(COMMENTS_INDEX_FILE, JSON.stringify(items, null, 2), "utf8");
};

const createEmptyModelRegistry = () => ({
    version: 1,
    assets: [],
    refs: [],
    shares: []
});

const normalizeModelRegistry = (registry) => ({
    version: Number(registry?.version) || 1,
    assets: Array.isArray(registry?.assets) ? registry.assets : [],
    refs: Array.isArray(registry?.refs) ? registry.refs : [],
    shares: Array.isArray(registry?.shares) ? registry.shares : []
});

const readModelRegistry = async () => {
    try {
        const raw = await fs.readFile(MODEL_REGISTRY_FILE, "utf8");
        return normalizeModelRegistry(JSON.parse(raw));
    } catch (err) {
        console.error("[server] failed to read model registry", err);
        return createEmptyModelRegistry();
    }
};

const writeModelRegistry = async (registry) => {
    await fs.writeFile(MODEL_REGISTRY_FILE, JSON.stringify(normalizeModelRegistry(registry), null, 2), "utf8");
};

const mutateModelRegistry = async (mutator) => {
    const mutation = modelRegistryMutationQueue.then(async () => {
        const registry = await readModelRegistry();
        const result = await mutator(registry);
        await writeModelRegistry(registry);
        return result;
    });
    modelRegistryMutationQueue = mutation.catch(() => {});
    return mutation;
};

const stringOrNull = (value) => {
    if (typeof value !== "string") {
        return null;
    }
    const trimmed = value.trim();
    return trimmed || null;
};

const normalizeRegistryUser = (user) => {
    if (!user || typeof user !== "object") {
        return null;
    }
    const id =
        stringOrNull(user.id) ||
        stringOrNull(user.authentikUserId) ||
        stringOrNull(user.subject) ||
        stringOrNull(user.sub) ||
        stringOrNull(user.email);
    if (!id) {
        return null;
    }
    return {
        id,
        email: stringOrNull(user.email),
        name: stringOrNull(user.name) || stringOrNull(user.preferredUsername),
        subject: stringOrNull(user.subject) || stringOrNull(user.sub),
        authentikUserId: stringOrNull(user.authentikUserId),
        groups: Array.isArray(user.groups) ? user.groups.filter((group) => typeof group === "string") : []
    };
};

const localDevRegistryUser = () => ({
    id: "local-dev",
    email: null,
    name: "Local BIM user",
    subject: null,
    authentikUserId: null,
    groups: []
});

const getRequestRegistryUser = (req) => {
    const sessionUser = normalizeRegistryUser(getCurrentBimSession(req)?.user);
    if (sessionUser) {
        return sessionUser;
    }
    return BIM_AUTH_REQUIRED ? null : localDevRegistryUser();
};

const shortHash = (value, length = 20) =>
    crypto.createHash("sha1").update(String(value || "")).digest("hex").slice(0, length);

const registryAssetKey = (identity) => {
    const projectId = safeProjectId(identity?.projectId);
    const assetId = safeAssetId(identity?.assetId);
    return projectId && assetId ? `${projectId}:${assetId}` : null;
};

const normalizeAssetIdentity = (identity) => {
    const projectId = safeProjectId(identity?.projectId);
    const assetId = safeAssetId(identity?.assetId);
    if (!projectId || !assetId) {
        return null;
    }
    return {
        projectId,
        assetId,
        assetKey: registryAssetKey({projectId, assetId}),
        legacy: !!identity?.legacy
    };
};

const getAssetIdentityFromUploadPath = (uploadPath) => {
    if (!uploadPath || !isPathInsideOrEqual(uploadPath, UPLOADS_DIR)) {
        return null;
    }
    const parts = path.relative(UPLOADS_DIR, uploadPath).split(path.sep).filter(Boolean);
    if (parts.length >= 3) {
        return normalizeAssetIdentity({
            projectId: parts[0],
            assetId: parts[1]
        });
    }
    if (parts.length >= 2) {
        return normalizeAssetIdentity({
            projectId: parts[0],
            assetId: `legacy_${shortHash(parts.join("/"))}`,
            legacy: true
        });
    }
    return null;
};

const getAssetIdentityFromSrc = (src) => {
    const uploadPath = resolveUploadSrc(src);
    return uploadPath ? getAssetIdentityFromUploadPath(uploadPath) : null;
};

const getAssetIdentityFromAssetDir = (assetDir) => {
    if (!assetDir || !isPathInsideOrEqual(assetDir, UPLOADS_DIR)) {
        return null;
    }
    const parts = path.relative(UPLOADS_DIR, assetDir).split(path.sep).filter(Boolean);
    if (parts.length < 2) {
        return null;
    }
    return normalizeAssetIdentity({
        projectId: parts[0],
        assetId: parts[1]
    });
};

const resolveAssetIdentityFromPayload = (payload) => {
    const direct = normalizeAssetIdentity({
        projectId: payload?.projectId,
        assetId: payload?.assetId
    });
    if (direct) {
        return direct;
    }
    const srcs = collectUploadSrcValues(payload);
    for (const src of srcs) {
        const identity = getAssetIdentityFromSrc(src);
        if (identity) {
            return identity;
        }
    }
    return null;
};

const summarizeRegistryUser = (user) => user ? {
    id: user.id,
    email: user.email || null,
    name: user.name || null,
    subject: user.subject || null,
    authentikUserId: user.authentikUserId || null
} : null;

const registryAssetFromVersionRecord = (versionRecord) => {
    const identity = normalizeAssetIdentity(versionRecord);
    if (!identity) {
        return null;
    }
    const sourceSrc = normalizeUploadSrcValue(versionRecord?.sourceSrc || versionRecord?.downloadSrc || versionRecord?.src);
    const downloadSrc = normalizeUploadSrcValue(versionRecord?.downloadSrc || versionRecord?.sourceSrc || versionRecord?.src) || sourceSrc;
    return {
        ...identity,
        originalFilename: stringOrNull(versionRecord?.originalFilename) || (sourceSrc ? path.basename(sourceSrc) : null),
        sourceSrc,
        downloadSrc,
        type: stringOrNull(versionRecord?.sourceFormat) || guessViewerTypeFromSrc(sourceSrc || "", "file"),
        status: stringOrNull(versionRecord?.status) || "ready",
        sha256: stringOrNull(versionRecord?.sha256),
        currentVersion: Number(versionRecord?.version) || null,
        currentVersionId: stringOrNull(versionRecord?.versionId),
        uploadedAt: stringOrNull(versionRecord?.uploadedAt) || nowIso(),
        versionRecord: {
            ...versionRecord,
            sourceSrc,
            downloadSrc
        }
    };
};

const upsertRegistryAsset = (registry, assetInput) => {
    const identity = normalizeAssetIdentity(assetInput);
    if (!identity) {
        return null;
    }
    const now = nowIso();
    const existingIndex = registry.assets.findIndex((asset) => asset?.assetKey === identity.assetKey);
    const existing = existingIndex >= 0 ? registry.assets[existingIndex] : null;
    const versions = Array.isArray(existing?.versions) ? [...existing.versions] : [];
    const versionRecord = assetInput?.versionRecord;
    if (versionRecord?.versionId) {
        const normalizedVersion = {
            ...versionRecord,
            sourceSrc: normalizeUploadSrcValue(versionRecord.sourceSrc || versionRecord.src) || versionRecord.sourceSrc || null,
            downloadSrc: normalizeUploadSrcValue(versionRecord.downloadSrc || versionRecord.sourceSrc || versionRecord.src) || versionRecord.downloadSrc || null
        };
        const versionIndex = versions.findIndex((version) => version?.versionId === normalizedVersion.versionId);
        if (versionIndex >= 0) {
            versions[versionIndex] = {...versions[versionIndex], ...normalizedVersion};
        } else {
            versions.push(normalizedVersion);
        }
        versions.sort((first, second) => Number(first?.version || 0) - Number(second?.version || 0));
    }

    const nextAsset = {
        ...(existing || {}),
        assetKey: identity.assetKey,
        assetId: identity.assetId,
        projectId: identity.projectId,
        legacy: !!identity.legacy,
        originalFilename: assetInput?.originalFilename || existing?.originalFilename || null,
        sourceSrc: assetInput?.sourceSrc || existing?.sourceSrc || null,
        downloadSrc: assetInput?.downloadSrc || existing?.downloadSrc || assetInput?.sourceSrc || null,
        type: assetInput?.type || existing?.type || null,
        status: assetInput?.status || existing?.status || "ready",
        sha256: assetInput?.sha256 || existing?.sha256 || null,
        currentVersion: assetInput?.currentVersion || existing?.currentVersion || null,
        currentVersionId: assetInput?.currentVersionId || existing?.currentVersionId || null,
        createdAt: existing?.createdAt || assetInput?.uploadedAt || now,
        updatedAt: now,
        deletedAt: null,
        physicallyDeletedAt: null,
        versions
    };
    if (existingIndex >= 0) {
        registry.assets[existingIndex] = nextAsset;
    } else {
        registry.assets.push(nextAsset);
    }
    return nextAsset;
};

const upsertRegistryAssetFromManifest = async (registry, identity) => {
    const normalized = normalizeAssetIdentity(identity);
    if (!normalized || normalized.legacy) {
        return null;
    }
    const assetDir = path.join(UPLOADS_DIR, normalized.projectId, normalized.assetId);
    const manifest = await readAssetManifest(assetDir);
    if (!manifest) {
        return upsertRegistryAsset(registry, {
            ...normalized,
            originalFilename: null,
            status: "unknown"
        });
    }
    const versions = Array.isArray(manifest.versions) ? manifest.versions : [];
    const currentVersion = versions.find((version) => version?.versionId === manifest.currentVersionId) ||
        versions.find((version) => Number(version?.version) === Number(manifest.currentVersion)) ||
        versions[versions.length - 1] ||
        null;
    const asset = upsertRegistryAsset(registry, {
        ...normalized,
        originalFilename: manifest.originalFilename || currentVersion?.originalFilename || null,
        sourceSrc: normalizeUploadSrcValue(currentVersion?.sourceSrc || currentVersion?.src) || null,
        downloadSrc: normalizeUploadSrcValue(currentVersion?.downloadSrc || currentVersion?.sourceSrc || currentVersion?.src) || null,
        type: currentVersion?.sourceFormat || guessViewerTypeFromSrc(currentVersion?.sourceSrc || currentVersion?.downloadSrc || "", "file"),
        status: currentVersion?.status || "ready",
        sha256: currentVersion?.sha256 || null,
        currentVersion: manifest.currentVersion || currentVersion?.version || null,
        currentVersionId: manifest.currentVersionId || currentVersion?.versionId || null
    });
    for (const version of versions) {
        upsertRegistryAsset(registry, {
            ...normalized,
            versionRecord: version
        });
    }
    return asset;
};

const upsertRegistryUserRef = (registry, identity, user, options = {}) => {
    const normalized = normalizeAssetIdentity(identity);
    const registryUser = normalizeRegistryUser(user);
    if (!normalized || !registryUser) {
        return null;
    }
    const kind = stringOrNull(options.kind) || "owner";
    const source = stringOrNull(options.source) || "upload";
    const refId = `user:${normalized.assetKey}:${registryUser.id}:${kind}:${source}`;
    const now = nowIso();
    const existingIndex = registry.refs.findIndex((ref) => ref?.id === refId);
    const existing = existingIndex >= 0 ? registry.refs[existingIndex] : null;
    const nextRef = {
        ...(existing || {}),
        id: refId,
        assetKey: normalized.assetKey,
        assetId: normalized.assetId,
        projectId: normalized.projectId,
        user: summarizeRegistryUser(registryUser),
        kind,
        source,
        versionId: stringOrNull(options.versionId) || existing?.versionId || null,
        src: normalizeUploadSrcValue(options.src) || existing?.src || null,
        settingsSrc: normalizeUploadSrcValue(options.settingsSrc) || existing?.settingsSrc || null,
        createdAt: existing?.createdAt || now,
        updatedAt: now,
        lastOpenedAt: options.touch ? now : existing?.lastOpenedAt || null,
        deletedAt: null
    };
    if (existingIndex >= 0) {
        registry.refs[existingIndex] = nextRef;
    } else {
        registry.refs.push(nextRef);
    }
    return nextRef;
};

const markRegistryUserRefsDeleted = (registry, identity, user) => {
    const normalized = normalizeAssetIdentity(identity);
    const registryUser = normalizeRegistryUser(user);
    if (!normalized || !registryUser) {
        return [];
    }
    const now = nowIso();
    const removed = [];
    for (const ref of registry.refs) {
        if (ref?.assetKey !== normalized.assetKey || ref?.user?.id !== registryUser.id || ref.deletedAt) {
            continue;
        }
        ref.deletedAt = now;
        ref.updatedAt = now;
        removed.push(ref);
    }
    return removed;
};

const upsertRegistryShareRef = (registry, identity, share) => {
    const normalized = normalizeAssetIdentity(identity);
    if (!normalized || !share?.token) {
        return null;
    }
    const refId = `share:${share.token}`;
    const now = nowIso();
    const existingIndex = registry.shares.findIndex((ref) => ref?.id === refId || ref?.token === share.token);
    const existing = existingIndex >= 0 ? registry.shares[existingIndex] : null;
    const nextRef = {
        ...(existing || {}),
        id: refId,
        token: share.token,
        assetKey: normalized.assetKey,
        assetId: normalized.assetId,
        projectId: normalized.projectId,
        access: share.access || "public-readonly",
        src: normalizeUploadSrcValue(share.src) || null,
        settingsSrc: normalizeUploadSrcValue(share.settingsSrc || share.src) || null,
        versionId: stringOrNull(share.versionId),
        createdAt: existing?.createdAt || share.createdAt || now,
        updatedAt: now,
        createdBy: share.createdBy || null,
        revokedAt: null
    };
    if (existingIndex >= 0) {
        registry.shares[existingIndex] = nextRef;
    } else {
        registry.shares.push(nextRef);
    }
    return nextRef;
};

const getActiveRegistryRefs = (registry, identity) => {
    const normalized = normalizeAssetIdentity(identity);
    if (!normalized) {
        return {userRefs: [], shareRefs: []};
    }
    return {
        userRefs: registry.refs.filter((ref) => ref?.assetKey === normalized.assetKey && !ref.deletedAt),
        shareRefs: registry.shares.filter((ref) => ref?.assetKey === normalized.assetKey && !ref.revokedAt)
    };
};

const getActiveRegistryRefsForVersion = (registry, identity, versionRecord, targetSrcs = new Set()) => {
    const normalized = normalizeAssetIdentity(identity);
    if (!normalized) {
        return {userRefs: [], shareRefs: []};
    }
    const normalizedTargetSrcs = new Set([...targetSrcs].map(normalizeUploadSrcValue).filter(Boolean));
    const versionId = stringOrNull(versionRecord?.versionId);
    const matchesVersionOrSrc = (ref) => {
        if (versionId && ref?.versionId === versionId) {
            return true;
        }
        const refSrcs = collectUploadSrcValues(ref);
        return [...refSrcs].some((src) => normalizedTargetSrcs.has(src));
    };
    return {
        userRefs: registry.refs.filter((ref) => ref?.assetKey === normalized.assetKey && !ref.deletedAt && matchesVersionOrSrc(ref)),
        shareRefs: registry.shares.filter((ref) => ref?.assetKey === normalized.assetKey && !ref.revokedAt && matchesVersionOrSrc(ref))
    };
};

const summarizeActiveRegistryRefs = (activeRefs) => ({
    userRefs: activeRefs.userRefs.length,
    shareRefs: activeRefs.shareRefs.length,
    total: activeRefs.userRefs.length + activeRefs.shareRefs.length
});

const markRegistryAssetPhysicallyDeleted = (registry, identity) => {
    const normalized = normalizeAssetIdentity(identity);
    if (!normalized) {
        return null;
    }
    const asset = registry.assets.find((item) => item?.assetKey === normalized.assetKey);
    if (!asset) {
        return null;
    }
    const now = nowIso();
    asset.deletedAt = asset.deletedAt || now;
    asset.physicallyDeletedAt = now;
    asset.updatedAt = now;
    return asset;
};

const removeRegistryVersion = (registry, identity, versionRecord) => {
    const normalized = normalizeAssetIdentity(identity);
    if (!normalized || !versionRecord?.versionId) {
        return null;
    }
    const asset = registry.assets.find((item) => item?.assetKey === normalized.assetKey);
    if (!asset || !Array.isArray(asset.versions)) {
        return null;
    }
    asset.versions = asset.versions.filter((version) => version?.versionId !== versionRecord.versionId);
    const nextCurrent = asset.versions.toSorted((first, second) => Number(second?.version || 0) - Number(first?.version || 0))[0] || null;
    asset.currentVersion = nextCurrent?.version || null;
    asset.currentVersionId = nextCurrent?.versionId || null;
    asset.updatedAt = nowIso();
    return asset;
};

const buildModelListItem = (asset, ref, registry) => {
    const shareCount = registry.shares.filter((share) => share?.assetKey === asset?.assetKey && !share.revokedAt).length;
    return {
        assetId: asset.assetId,
        projectId: asset.projectId,
        assetKey: asset.assetKey,
        name: asset.originalFilename || path.basename(asset.downloadSrc || asset.sourceSrc || asset.assetId),
        src: asset.downloadSrc || asset.sourceSrc || null,
        settingsSrc: asset.sourceSrc || asset.downloadSrc || null,
        type: asset.type || guessViewerTypeFromSrc(asset.downloadSrc || asset.sourceSrc || "", null),
        status: asset.status || "ready",
        currentVersion: asset.currentVersion || null,
        currentVersionId: asset.currentVersionId || null,
        updatedAt: asset.updatedAt || asset.createdAt || null,
        ref: {
            id: ref.id,
            kind: ref.kind,
            source: ref.source,
            createdAt: ref.createdAt,
            updatedAt: ref.updatedAt
        },
        shareCount
    };
};

const sendJSON = (res, status, payload) => {
    setCors(res);
    res.statusCode = status;
    res.setHeader("Content-Type", "application/json");
    res.end(JSON.stringify(payload));
};

const sendText = (res, status, message) => {
    setCors(res);
    res.statusCode = status;
    res.setHeader("Content-Type", "text/plain");
    res.end(message);
};

const truncateLogValue = (value, maxLength = 2400) => {
    const text = typeof value === "string" ? value : JSON.stringify(value);
    return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
};

const resolveCorsOrigin = (origin) => {
    if (!origin) {
        return "*";
    }
    if (!CORS_ALLOWED_ORIGINS.length || CORS_ALLOWED_ORIGINS.includes(origin)) {
        return origin;
    }
    return null;
};

const setCors = (reqOrRes, maybeRes = null) => {
    const req = maybeRes ? reqOrRes : null;
    const res = maybeRes || reqOrRes;
    const origin = req?.headers?.origin || res.__nodedcCorsOrigin || "";
    const allowedOrigin = resolveCorsOrigin(origin) || "null";
    res.__nodedcCorsOrigin = allowedOrigin;
    res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
    res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With");
    res.setHeader("Access-Control-Allow-Credentials", "true");
    if (origin) {
        res.setHeader("Vary", "Origin");
    }
};

const parseJSONBody = async (req) => {
    const chunks = [];
    for await (const chunk of req) {
        chunks.push(chunk);
    }
    if (!chunks.length) {
        return {};
    }
    const raw = Buffer.concat(chunks).toString("utf8");
    if (!raw.trim()) {
        return {};
    }
    try {
        return JSON.parse(raw);
    } catch (err) {
        throw new Error("Invalid JSON");
    }
};

const randomBase64Url = (bytes = 24) => crypto.randomBytes(bytes).toString("base64url");

const sanitizeReturnTo = (value, fallback = "/") => {
    if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) {
        return fallback;
    }
    return value;
};

const parseCookies = (cookieHeader) => {
    if (!cookieHeader) {
        return {};
    }
    return Object.fromEntries(
        String(cookieHeader).split(";").flatMap((part) => {
            const separatorIndex = part.indexOf("=");
            if (separatorIndex === -1) {
                return [];
            }
            const key = part.slice(0, separatorIndex).trim();
            const value = part.slice(separatorIndex + 1).trim();
            return [[key, decodeURIComponent(value)]];
        })
    );
};

const buildCookieHeader = (name, value, maxAgeSeconds) => {
    const parts = [
        `${name}=${encodeURIComponent(value)}`,
        "Path=/",
        "HttpOnly",
        `SameSite=${BIM_COOKIE_SAMESITE}`,
        `Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`
    ];
    if (BIM_COOKIE_SECURE) {
        parts.push("Secure");
    }
    return parts.join("; ");
};

const clearBimSessionCookie = (res) => {
    res.setHeader("Set-Cookie", buildCookieHeader(BIM_SESSION_COOKIE, "", 0));
};

const pruneExpiredBimSessions = () => {
    const now = Date.now();
    for (const [id, session] of bimSessions.entries()) {
        if (!session?.expiresAt || session.expiresAt <= now) {
            bimSessions.delete(id);
        }
    }
};

const createBimSession = (res, user) => {
    pruneExpiredBimSessions();
    const id = randomBase64Url(32);
    const session = {
        id,
        user,
        createdAt: nowIso(),
        expiresAt: Date.now() + BIM_SESSION_TTL_MS
    };
    bimSessions.set(id, session);
    res.setHeader("Set-Cookie", buildCookieHeader(BIM_SESSION_COOKIE, id, BIM_SESSION_TTL_MS / 1000));
    return session;
};

const getCurrentBimSession = (req) => {
    const sessionId = parseCookies(req.headers.cookie || "")[BIM_SESSION_COOKIE];
    if (!sessionId) {
        return null;
    }
    const session = bimSessions.get(sessionId);
    if (!session || session.expiresAt <= Date.now()) {
        bimSessions.delete(sessionId);
        return null;
    }
    return session;
};

const getRequestBaseUrl = (req) => {
    if (BIM_PUBLIC_BASE_URL) {
        return BIM_PUBLIC_BASE_URL;
    }
    const proto = req.headers["x-forwarded-proto"] || "http";
    const host = req.headers["x-forwarded-host"] || req.headers.host || `localhost:${PORT}`;
    return `${proto}://${host}`.replace(/\/$/, "");
};

const buildLauncherLoginUrl = (req, nextPath = "/") => {
    const launchPath = `/api/services/${encodeURIComponent(BIM_SERVICE_SLUG)}/launch`;
    const launchUrl = new URL(launchPath, LAUNCHER_BASE_URL);
    launchUrl.searchParams.set("returnTo", sanitizeReturnTo(nextPath));
    const loginUrl = new URL("/auth/login", LAUNCHER_BASE_URL);
    loginUrl.searchParams.set("returnTo", `${launchUrl.pathname}${launchUrl.search}`);
    return loginUrl.toString();
};

const sendAuthRequired = (req, res, url) => {
    const nextPath = sanitizeReturnTo(`${url.pathname}${url.search || ""}`);
    if (!INTERNAL_ACCESS_TOKEN) {
        sendText(res, 503, "BIM auth is enabled but NODEDC_INTERNAL_ACCESS_TOKEN is not configured.");
        return;
    }
    if (isHtmlNavigation(req, url)) {
        res.statusCode = 302;
        res.setHeader("Location", buildLauncherLoginUrl(req, nextPath));
        res.end();
        return;
    }
    sendJSON(res, 401, {
        ok: false,
        authenticated: false,
        error: "bim_auth_required",
        loginUrl: buildLauncherLoginUrl(req, nextPath)
    });
};

const isHtmlNavigation = (req, url) => {
    if (req.method !== "GET" && req.method !== "HEAD") {
        return false;
    }
    if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/auth/")) {
        return false;
    }
    const accept = typeof req.headers.accept === "string" ? req.headers.accept : "";
    return url.pathname === "/" || (!path.extname(url.pathname) && accept.includes("text/html"));
};

const isPublicSharePath = (url) => /^\/share\/[^/]+\/?$/.test(url.pathname);

const requestNeedsBimSession = (req, url) => {
    if (!BIM_AUTH_REQUIRED) {
        return false;
    }
    if (url.pathname.startsWith("/auth/") || url.pathname === "/api/auth/session") {
        return false;
    }
    if (req.method === "GET" && /^\/api\/shares\/[^/]+\/?$/.test(url.pathname)) {
        return false;
    }
    if (isPublicSharePath(url)) {
        return false;
    }
    if (url.pathname === "/api/client-log") {
        return false;
    }
    if (url.pathname.startsWith("/uploads/") || url.pathname.startsWith("/data/")) {
        return false;
    }
    if (url.pathname.startsWith("/api/models")) {
        return true;
    }
    if (url.pathname.startsWith("/api/comments")) {
        return true;
    }
    if (url.pathname.startsWith("/api/projects")) {
        return true;
    }
    if (url.pathname.startsWith("/api/")) {
        return req.method !== "GET";
    }
    return isHtmlNavigation(req, url);
};

const ensureBimSessionForRequest = (req, res, url) => {
    if (!requestNeedsBimSession(req, url)) {
        return false;
    }
    if (getCurrentBimSession(req)) {
        return false;
    }
    sendAuthRequired(req, res, url);
    return true;
};

const consumeLauncherHandoff = async (token) => {
    if (!INTERNAL_ACCESS_TOKEN) {
        throw new Error("NODEDC_INTERNAL_ACCESS_TOKEN is not configured.");
    }
    const response = await fetch(new URL("/api/internal/handoff/consume", LAUNCHER_INTERNAL_BASE_URL), {
        method: "POST",
        headers: {
            "Authorization": `Bearer ${INTERNAL_ACCESS_TOKEN}`,
            "Content-Type": "application/json"
        },
        body: JSON.stringify({
            token,
            serviceSlug: BIM_SERVICE_SLUG
        })
    });
    const payload = await response.json().catch(() => null);
    if (!response.ok || !payload?.ok) {
        throw new Error(payload?.error || `Launcher handoff failed: HTTP ${response.status}`);
    }
    return payload.user;
};

const safeProjectId = (id) => {
    if (typeof id !== "string") {
        return null;
    }
    const trimmed = id.trim();
    if (!/^[-_a-zA-Z0-9]+$/.test(trimmed)) {
        return null;
    }
    return trimmed;
};

const safeAssetId = (id) => {
    if (typeof id !== "string") {
        return null;
    }
    const trimmed = id.trim();
    if (!/^[-_a-zA-Z0-9]+$/.test(trimmed)) {
        return null;
    }
    return trimmed;
};

const assetManifestPath = (assetDir) => path.join(assetDir, "asset.json");

const readAssetManifest = async (assetDir) => {
    const manifest = await readJSONFile(assetManifestPath(assetDir));
    return manifest && typeof manifest === "object" ? manifest : null;
};

const writeAssetManifest = async (assetDir, manifest) => {
    await writeJSONFile(assetManifestPath(assetDir), manifest);
};

const calculateFileSha256 = async (filePath) => {
    const hash = crypto.createHash("sha256");
    await pipeline(createReadStream(filePath), hash);
    return hash.digest("hex");
};

const fileExists = async (filePath) => {
    try {
        const stats = await fs.stat(filePath);
        return stats.isFile();
    } catch (_) {
        return false;
    }
};

const linkOrCopyUploadFile = async (sourcePath, targetPath) => {
    if (path.resolve(sourcePath) === path.resolve(targetPath)) {
        return "same";
    }
    try {
        await fs.unlink(targetPath);
    } catch (_) {
        // the upload target may already have been removed by a failed retry
    }
    try {
        await fs.link(sourcePath, targetPath);
        return "hardlink";
    } catch (err) {
        await fs.copyFile(sourcePath, targetPath);
        return "copy";
    }
};

const findReusableOriginalBySha256 = async ({sha256, sourceFormat, targetPath}) => {
    if (!sha256) {
        return null;
    }
    const targetResolved = path.resolve(targetPath);
    const dirs = [UPLOADS_DIR];
    while (dirs.length) {
        const dir = dirs.pop();
        let entries = [];
        try {
            entries = await fs.readdir(dir, {withFileTypes: true});
        } catch (_) {
            continue;
        }

        for (const entry of entries) {
            const entryPath = path.join(dir, entry.name);
            if (entry.isDirectory()) {
                dirs.push(entryPath);
                continue;
            }
            if (!entry.isFile() || !entry.name.endsWith(".beam.json")) {
                continue;
            }

            const manifest = await readJSONFile(entryPath);
            if (!manifest || typeof manifest !== "object") {
                continue;
            }
            if (sourceFormat && manifest.sourceFormat && manifest.sourceFormat !== sourceFormat) {
                continue;
            }

            const sourcePath = resolveUploadSrc(manifest.sourceSrc || manifest.downloadSrc);
            if (!sourcePath || path.resolve(sourcePath) === targetResolved) {
                continue;
            }
            if (!(await fileExists(sourcePath))) {
                continue;
            }

            let candidateSha256 = manifest.sha256;
            if (!candidateSha256) {
                candidateSha256 = await calculateFileSha256(sourcePath);
                await writeJSONFile(entryPath, {...manifest, sha256: candidateSha256});
            }
            if (candidateSha256 !== sha256) {
                continue;
            }
            return {
                sourcePath,
                sourceSrc: srcFromUploadPath(sourcePath)
            };
        }
    }
    return null;
};

const nextAssetVersionNumber = (assetManifest, requestedVersion) => {
    const versions = Array.isArray(assetManifest?.versions) ? assetManifest.versions : [];
    const maxVersion = versions.reduce((max, version) => {
        const value = Number(version?.version);
        return Number.isFinite(value) ? Math.max(max, value) : max;
    }, 0);
    if (Number.isFinite(requestedVersion) && requestedVersion > maxVersion) {
        return requestedVersion;
    }
    return maxVersion + 1;
};

const buildVersionId = (version) => `ver_${String(version).padStart(4, "0")}`;

const upsertAssetVersion = async (assetDir, versionRecord) => {
    const existing = await readAssetManifest(assetDir);
    const now = nowIso();
    const versions = Array.isArray(existing?.versions) ? existing.versions : [];
    const nextVersions = [
        ...versions.filter((version) => version?.versionId !== versionRecord.versionId),
        versionRecord
    ].sort((first, second) => Number(first.version || 0) - Number(second.version || 0));

    const nextManifest = {
        assetId: versionRecord.assetId,
        projectId: versionRecord.projectId,
        originalFilename: existing?.originalFilename || versionRecord.originalFilename,
        createdAt: existing?.createdAt || now,
        updatedAt: now,
        currentVersion: versionRecord.version,
        currentVersionId: versionRecord.versionId,
        versions: nextVersions
    };
    await writeAssetManifest(assetDir, nextManifest);
    return nextManifest;
};

const STATIC_ROOTS = [
    {prefix: "/uploads/", dir: UPLOADS_DIR},
    {prefix: "/data/", dir: DATA_ROOT},
    {prefix: "/", dir: FRONTEND_DIST},
    {prefix: "/", dir: FRONTEND_ROOT}
];

const resolveStaticPath = async (pathname) => {
    for (const {prefix, dir} of STATIC_ROOTS) {
        if (!pathname.startsWith(prefix)) {
            continue;
        }
        const rel = pathname.slice(prefix.length);
        const root = path.resolve(dir);
        const resolved = path.resolve(root, rel);
        if (!isPathInsideOrEqual(resolved, root)) {
            continue;
        }
        try {
            const stats = await fs.stat(resolved);
            if (stats.isFile()) {
                return resolved;
            }
        } catch (_) {
            // keep searching
        }
    }
    return null;
};

const serveStatic = async (req, res, url) => {
    const pathname = url.pathname || "/";
    const targetPath = await resolveStaticPath(pathname);
    if (!targetPath) {
        const shouldFallback = pathname === "/" || !path.extname(pathname);
        if (shouldFallback) {
            const fallbackIndex = await resolveStaticPath("/index.html");
            if (fallbackIndex) {
                return serveStatic(req, res, new URL("/index.html", url));
            }
        }
        sendText(res, 404, "Not Found");
        return;
    }
    const ext = path.extname(targetPath).toLowerCase();
    const mime = MIME_TYPES[ext] || "application/octet-stream";
    res.statusCode = 200;
    res.setHeader("Content-Type", mime);
    createReadStream(targetPath).pipe(res).on("error", (err) => {
        console.error("[server] failed to stream file", err);
        if (!res.headersSent) {
            sendText(res, 500, "Failed to read file");
        } else {
            res.destroy();
        }
    });
};

const decodeBase64 = (b64) => {
    try {
        return Buffer.from(b64, "base64");
    } catch (err) {
        return null;
    }
};

const sanitizeFilename = (name, fallback) => {
    if (!name || typeof name !== "string") {
        return fallback;
    }
    const safe = name.replace(/[^a-zA-Z0-9._-]/g, "_");
    return safe || fallback;
};

const manifestPathForSource = (sourcePath) => path.join(path.dirname(sourcePath), `${path.basename(sourcePath)}.beam.json`);

const isPathInsideOrEqual = (targetPath, rootPath) => {
    const target = path.resolve(targetPath);
    const root = path.resolve(rootPath);
    return target === root || target.startsWith(`${root}${path.sep}`);
};

const srcFromUploadPath = (uploadPath) => {
    const relative = path.relative(UPLOADS_DIR, uploadPath).replace(/\\/g, "/");
    return path.join("uploads", relative).replace(/\\/g, "/");
};

const resolveUploadSrc = (src) => {
    if (!src || typeof src !== "string") {
        return null;
    }
    let value = src.trim();
    if (!value) {
        return null;
    }
    try {
        if (/^https?:\/\//i.test(value)) {
            const parsed = new URL(value);
            value = parsed.pathname;
        }
    } catch (_) {
        return null;
    }
    value = value.replace(/^\/+/, "");
    if (!value.startsWith("uploads/")) {
        return null;
    }
    const relative = value.slice("uploads/".length);
    const resolved = path.resolve(UPLOADS_DIR, relative);
    if (!isPathInsideOrEqual(resolved, UPLOADS_DIR)) {
        return null;
    }
    return resolved;
};

const normalizeUploadSrcValue = (src) => {
    const resolved = resolveUploadSrc(src);
    return resolved ? srcFromUploadPath(resolved) : null;
};

const VIEWER_TYPE_BY_EXTENSION = new Map([
    [".xkt", "xkt"],
    [".glb", "gltf"],
    [".gltf", "gltf"],
    [".bim", "bim"],
    [".las", "las"],
    [".laz", "las"],
    [".obj", "obj"],
    [".stl", "stl"]
]);

const guessViewerTypeFromSrc = (src, fallback = null) => {
    const pathname = (() => {
        try {
            return new URL(src, "http://localhost").pathname;
        } catch (_) {
            return src || "";
        }
    })();
    return VIEWER_TYPE_BY_EXTENSION.get(path.extname(pathname).toLowerCase()) || fallback;
};

const publicUrlForUploadSrc = (req, src) => new URL(`/${src.replace(/^\/+/, "")}`, `${getRequestBaseUrl(req)}/`).toString();

const safeShareToken = (token) => {
    if (typeof token !== "string") {
        return null;
    }
    const value = token.trim();
    return /^[A-Za-z0-9_-]{12,96}$/.test(value) ? value : null;
};

const shareTokenFromPath = (pathname) => safeShareToken(pathname.split("/")[3] || pathname.split("/")[2]);

const collectUploadSrcValues = (value, srcs = new Set()) => {
    if (!value) {
        return srcs;
    }
    if (typeof value === "string") {
        const normalized = normalizeUploadSrcValue(value);
        if (normalized) {
            srcs.add(normalized);
        }
        try {
            const parsed = new URL(value);
            const nestedUrl = parsed.searchParams.get("url");
            const settingsSrc = parsed.searchParams.get("settingsSrc");
            if (nestedUrl) collectUploadSrcValues(nestedUrl, srcs);
            if (settingsSrc) collectUploadSrcValues(settingsSrc, srcs);
        } catch (_) {
            // not a URL; already handled as a possible upload src
        }
        return srcs;
    }
    if (Array.isArray(value)) {
        for (const item of value) {
            collectUploadSrcValues(item, srcs);
        }
        return srcs;
    }
    if (typeof value === "object") {
        for (const item of Object.values(value)) {
            collectUploadSrcValues(item, srcs);
        }
    }
    return srcs;
};

const getAssetDirFromSrc = (src) => {
    const resolved = resolveUploadSrc(src);
    if (!resolved) {
        return null;
    }
    const relativeParts = path.relative(UPLOADS_DIR, resolved).split(path.sep).filter(Boolean);
    if (relativeParts.length < 3) {
        return null;
    }
    const projectId = safeProjectId(relativeParts[0]);
    const assetId = safeAssetId(relativeParts[1]);
    if (!projectId || !assetId) {
        return null;
    }
    return path.join(UPLOADS_DIR, projectId, assetId);
};

const resolveAssetDirFromPayload = (payload) => {
    const projectId = safeProjectId(payload?.projectId);
    const assetId = safeAssetId(payload?.assetId);
    if (projectId && assetId) {
        return path.join(UPLOADS_DIR, projectId, assetId);
    }

    const srcs = collectUploadSrcValues(payload);
    for (const src of srcs) {
        const assetDir = getAssetDirFromSrc(src);
        if (assetDir) {
            return assetDir;
        }
    }
    return null;
};

const getVersionDirFromVersionRecord = (versionRecord, assetDir = null) => {
    const sourcePath = resolveUploadSrc(versionRecord?.sourceSrc || versionRecord?.downloadSrc || versionRecord?.src);
    if (!sourcePath) {
        return assetDir;
    }
    const versionDir = path.dirname(sourcePath);
    if (assetDir && !isPathInsideOrEqual(versionDir, assetDir)) {
        return assetDir;
    }
    return versionDir;
};

const removeEmptyDirsUpTo = async (startDir, stopDir) => {
    let current = path.resolve(startDir);
    const stop = path.resolve(stopDir);
    while (isPathInsideOrEqual(current, stop) && current !== stop) {
        try {
            const entries = await fs.readdir(current);
            if (entries.length > 0) {
                return;
            }
            await fs.rmdir(current);
        } catch (_) {
            return;
        }
        current = path.dirname(current);
    }
};

const deleteUploadSrcFiles = async (srcs, protectedSrcs = new Set(), cleanupStopDir = UPLOADS_DIR, deleteRootDir = cleanupStopDir) => {
    const deletedFiles = [];
    for (const src of srcs) {
        const normalized = normalizeUploadSrcValue(src);
        if (!normalized || protectedSrcs.has(normalized)) {
            continue;
        }
        const filePath = resolveUploadSrc(normalized);
        if (!filePath) {
            continue;
        }
        if (deleteRootDir && !isPathInsideOrEqual(filePath, deleteRootDir)) {
            continue;
        }
        try {
            const stats = await fs.stat(filePath);
            if (!stats.isFile()) {
                continue;
            }
            await fs.unlink(filePath);
            deletedFiles.push(normalized);
            await removeEmptyDirsUpTo(path.dirname(filePath), cleanupStopDir);
        } catch (_) {
            // idempotent cleanup: already removed files are fine
        }
    }
    return deletedFiles;
};

const deleteUploadSrcsWithLocalManifestScope = async (requestedSrcs, protectedSrcs = new Set()) => {
    const deletedFiles = [];
    for (const src of requestedSrcs) {
        const sourcePath = resolveUploadSrc(src);
        if (!sourcePath) {
            continue;
        }
        const scopedSrcs = new Set([src]);
        const manifest = await readJSONFile(manifestPathForSource(sourcePath));
        collectUploadSrcValues(manifest, scopedSrcs);
        const scopeDir = path.dirname(sourcePath);
        deletedFiles.push(...await deleteUploadSrcFiles(scopedSrcs, protectedSrcs, scopeDir, scopeDir));
    }
    return deletedFiles;
};

const readManifestForVersionRecord = async (versionRecord) => {
    const sourcePath = resolveUploadSrc(versionRecord?.sourceSrc || versionRecord?.downloadSrc || versionRecord?.src);
    if (!sourcePath) {
        return null;
    }
    return readJSONFile(manifestPathForSource(sourcePath));
};

const collectVersionUploadSrcs = async (versionRecord) => {
    const srcs = collectUploadSrcValues(versionRecord);
    const manifest = await readManifestForVersionRecord(versionRecord);
    collectUploadSrcValues(manifest, srcs);
    return srcs;
};

const handleAuthSession = (req, res) => {
    const session = getCurrentBimSession(req);
    sendJSON(res, 200, {
        ok: true,
        authRequired: BIM_AUTH_REQUIRED,
        authenticated: !!session,
        canShare: !BIM_AUTH_REQUIRED || !!session,
        user: session?.user || null,
        loginUrl: session ? null : buildLauncherLoginUrl(req, "/")
    });
};

const handleLauncherHandoff = async (req, res, url) => {
    const token = url.searchParams.get("token") || "";
    const nextPath = sanitizeReturnTo(url.searchParams.get("next_path") || url.searchParams.get("returnTo") || "/");
    if (!token) {
        sendText(res, 400, "Missing handoff token");
        return;
    }
    try {
        const user = await consumeLauncherHandoff(token);
        createBimSession(res, user);
        res.statusCode = 302;
        res.setHeader("Location", nextPath);
        res.end();
    } catch (err) {
        console.error("[server] Launcher handoff failed", err);
        sendText(res, 403, err?.message || "Launcher handoff failed");
    }
};

const handleLogout = (req, res) => {
    const sessionId = parseCookies(req.headers.cookie || "")[BIM_SESSION_COOKIE];
    if (sessionId) {
        bimSessions.delete(sessionId);
    }
    clearBimSessionCookie(res);
    res.statusCode = 302;
    res.setHeader("Location", "/");
    res.end();
};

const handleCreateShare = async (req, res) => {
    const session = getCurrentBimSession(req);
    if (BIM_AUTH_REQUIRED && !session) {
        sendJSON(res, 401, {ok: false, error: "bim_auth_required"});
        return;
    }

    let payload = {};
    try {
        payload = await parseJSONBody(req);
    } catch (err) {
        sendText(res, 400, err.message || "Invalid JSON");
        return;
    }

    const src = normalizeUploadSrcValue(payload?.src || payload?.url || payload?.artifactSrc);
    if (!src) {
        sendText(res, 400, "Share source must point to a BIM upload artifact.");
        return;
    }

    const settingsSrc = normalizeUploadSrcValue(payload?.settingsSrc || payload?.sourceSrc || payload?.originalSrc) || src;
    const identity = resolveAssetIdentityFromPayload({...payload, src, settingsSrc});
    if (!identity) {
        sendText(res, 400, "Share source is not registered as a BIM model asset.");
        return;
    }
    const token = randomBase64Url(24);
    const now = nowIso();
    const type = String(payload?.type || guessViewerTypeFromSrc(src, "xkt")).trim().toLowerCase();
    const name = typeof payload?.name === "string" && payload.name.trim()
        ? payload.name.trim().slice(0, 240)
        : path.basename(src);
    const share = {
        token,
        access: "public-readonly",
        src,
        settingsSrc,
        type,
        name,
        assetId: identity.assetId,
        projectId: identity.projectId,
        versionId: typeof payload?.versionId === "string" ? payload.versionId : null,
        createdAt: now,
        createdBy: session?.user ? {
            id: session.user.id || null,
            email: session.user.email || null,
            name: session.user.name || null,
            subject: session.user.subject || null
        } : {
            id: "local-dev",
            email: null,
            name: "Local BIM user",
            subject: null
        }
    };

    const shares = await readSharesIndex();
    shares.push(share);
    await writeSharesIndex(shares);
    await mutateModelRegistry(async (registry) => {
        const asset = identity.legacy
            ? upsertRegistryAsset(registry, {
                ...identity,
                originalFilename: name,
                sourceSrc: settingsSrc,
                downloadSrc: src,
                type,
                status: "ready",
                currentVersionId: share.versionId
            })
            : await upsertRegistryAssetFromManifest(registry, identity);
        upsertRegistryAsset(registry, asset || {
            ...identity,
            originalFilename: name,
            sourceSrc: settingsSrc,
            downloadSrc: src,
            type,
            status: "ready",
            currentVersionId: share.versionId
        });
        upsertRegistryShareRef(registry, identity, share);
    });

    sendJSON(res, 201, {
        ok: true,
        token,
        url: new URL(`/share/${token}`, `${getRequestBaseUrl(req)}/`).toString(),
        share
    });
};

const handleGetShare = async (req, res, token) => {
    const safeToken = safeShareToken(token);
    if (!safeToken) {
        sendText(res, 404, "Share not found");
        return;
    }
    const shares = await readSharesIndex();
    const share = shares.find((item) => item?.token === safeToken);
    if (!share) {
        sendText(res, 404, "Share not found");
        return;
    }
    sendJSON(res, 200, {
        ok: true,
        mode: "guest",
        readonly: true,
        viewer: {
            src: publicUrlForUploadSrc(req, share.src),
            settingsSrc: publicUrlForUploadSrc(req, share.settingsSrc || share.src),
            type: share.type || guessViewerTypeFromSrc(share.src, "xkt"),
            name: share.name || path.basename(share.src),
            assetId: share.assetId || null,
            versionId: share.versionId || null
        },
        share: {
            token: share.token,
            access: share.access,
            createdAt: share.createdAt,
            createdBy: share.createdBy ? {
                name: share.createdBy.name || null
            } : null
        }
    });
};

const sanitizeCommentText = (value, maxLength = 4000) => {
    if (typeof value !== "string") {
        return "";
    }
    return value.trim().slice(0, maxLength);
};

const normalizeCommentWorldPos = (value) => {
    if (!Array.isArray(value) || value.length !== 3) {
        return null;
    }
    const next = value.map((item) => Number(item));
    return next.every((item) => Number.isFinite(item)) ? next : null;
};

const sanitizeCommentAttachments = (value) => {
    if (!Array.isArray(value)) {
        return [];
    }
    return value.slice(0, 8).flatMap((item) => {
        if (!item || typeof item !== "object") {
            return [];
        }
        const dataUrl = typeof item.dataUrl === "string" ? item.dataUrl : "";
        if (!/^data:[^;,]+\/?[^;,]*;base64,/i.test(dataUrl)) {
            return [];
        }
        const size = Number(item.size);
        if (Number.isFinite(size) && size > 50 * 1024 * 1024) {
            return [];
        }
        if (dataUrl.length > 70_000_000) {
            return [];
        }
        return [{
            id: stringOrNull(item.id) || `att_${randomBase64Url(8)}`,
            name: sanitizeFilename(item.name || "file", "file"),
            type: stringOrNull(item.type) || "application/octet-stream",
            size: Number.isFinite(size) ? size : null,
            dataUrl
        }];
    });
};

const sanitizeCommentSubitems = (value) => {
    if (!Array.isArray(value)) {
        return [];
    }
    return value.slice(0, 100).flatMap((item) => {
        if (!item || typeof item !== "object") {
            return [];
        }
        const kind = item.kind === "text" ? "text" : "checker";
        const rawContent = kind === "text"
            ? (item.body ?? item.title ?? item.text ?? item.name)
            : (item.title ?? item.text ?? item.name ?? item.body);
        const content = sanitizeCommentText(rawContent, kind === "text" ? 4000 : 300);
        if (!content) {
            return [];
        }
        return [{
            id: stringOrNull(item.id) || `sub_${randomBase64Url(8)}`,
            kind,
            title: content,
            body: kind === "text" ? content : "",
            done: kind === "checker" && item.done === true,
            createdAt: stringOrNull(item.createdAt) || nowIso()
        }];
    });
};

const resolveCommentSource = (payload = {}) => {
    const identity = resolveAssetIdentityFromPayload(payload);
    const rawSrc = stringOrNull(payload.src) ||
        stringOrNull(payload.url) ||
        stringOrNull(payload.sourceSrc) ||
        stringOrNull(payload.settingsSrc) ||
        stringOrNull(payload.artifactSrc);
    const sourceSrc = normalizeUploadSrcValue(rawSrc) || rawSrc || null;
    if (identity) {
        return {
            sourceKey: identity.assetKey,
            projectId: identity.projectId,
            assetId: identity.assetId,
            sourceSrc
        };
    }
    if (!sourceSrc) {
        return null;
    }
    return {
        sourceKey: `src:${shortHash(sourceSrc, 24)}`,
        projectId: safeProjectId(payload.projectId) || null,
        assetId: safeAssetId(payload.assetId) || null,
        sourceSrc
    };
};

const buildCommentMessage = (payload, user, fallbackBody = "") => {
    const source = typeof payload === "string" ? {body: payload} : (payload || {});
    const body = sanitizeCommentText(source.body ?? source.text ?? source.message ?? fallbackBody);
    const attachments = sanitizeCommentAttachments(source.attachments);
    if (!body && !attachments.length) {
        return null;
    }
    const now = nowIso();
    return {
        id: `msg_${randomBase64Url(10)}`,
        body,
        attachments,
        createdAt: now,
        updatedAt: now,
        createdBy: summarizeRegistryUser(user)
    };
};

const publicComment = (comment) => ({
    id: comment.id,
    sourceKey: comment.sourceKey,
    sourceSrc: comment.sourceSrc || null,
    projectId: comment.projectId || null,
    assetId: comment.assetId || null,
    modelId: comment.modelId || null,
    objectId: comment.objectId || null,
    title: comment.title || "",
    body: comment.body || "",
    subitems: Array.isArray(comment.subitems) ? comment.subitems : [],
    worldPos: comment.worldPos || null,
    createdAt: comment.createdAt,
    updatedAt: comment.updatedAt,
    createdBy: comment.createdBy || null,
    messages: Array.isArray(comment.messages) ? comment.messages : []
});

const handleGetComments = async (req, res, url) => {
    const user = requireModelRegistryUser(req, res);
    if (!user) {
        return;
    }
    const source = resolveCommentSource(Object.fromEntries(url.searchParams.entries()));
    if (!source?.sourceKey) {
        sendJSON(res, 200, {ok: true, user: summarizeRegistryUser(user), comments: []});
        return;
    }
    const items = await readCommentsIndex();
    const comments = items
        .filter((item) => item?.sourceKey === source.sourceKey && !item.deletedAt)
        .map(publicComment)
        .toSorted((first, second) => String(second.updatedAt || "").localeCompare(String(first.updatedAt || "")));
    sendJSON(res, 200, {
        ok: true,
        user: summarizeRegistryUser(user),
        source,
        comments
    });
};

const handleCreateComment = async (req, res) => {
    const user = requireModelRegistryUser(req, res);
    if (!user) {
        return;
    }
    let payload = {};
    try {
        payload = await parseJSONBody(req);
    } catch (err) {
        sendText(res, 400, err.message || "Invalid JSON");
        return;
    }
    const source = resolveCommentSource(payload);
    const worldPos = normalizeCommentWorldPos(payload?.worldPos);
    const title = sanitizeCommentText(payload?.title, 180);
    const body = sanitizeCommentText(payload?.body ?? payload?.description, 8000);
    if (!source?.sourceKey) {
        sendText(res, 400, "Comment source is required");
        return;
    }
    if (!worldPos) {
        sendText(res, 400, "Comment worldPos is required");
        return;
    }
    if (!title && !body) {
        sendText(res, 400, "Comment title or body is required");
        return;
    }
    const now = nowIso();
    const firstMessage = buildCommentMessage({
        body,
        attachments: payload?.attachments
    }, user);
    const comment = {
        id: `cmt_${randomBase64Url(14)}`,
        sourceKey: source.sourceKey,
        sourceSrc: source.sourceSrc || null,
        projectId: source.projectId || null,
        assetId: source.assetId || null,
        modelId: stringOrNull(payload?.modelId),
        objectId: stringOrNull(payload?.objectId),
        title: title || "Комментарий",
        body,
        subitems: sanitizeCommentSubitems(payload?.subitems),
        worldPos,
        createdAt: now,
        updatedAt: now,
        createdBy: summarizeRegistryUser(user),
        messages: firstMessage ? [firstMessage] : []
    };
    const items = await readCommentsIndex();
    items.unshift(comment);
    await writeCommentsIndex(items);
    sendJSON(res, 201, {ok: true, comment: publicComment(comment)});
};

const handlePatchComment = async (req, res, commentId) => {
    const user = requireModelRegistryUser(req, res);
    if (!user) {
        return;
    }
    const safeId = stringOrNull(commentId);
    if (!safeId) {
        sendText(res, 400, "Invalid comment id");
        return;
    }
    let payload = {};
    try {
        payload = await parseJSONBody(req);
    } catch (err) {
        sendText(res, 400, err.message || "Invalid JSON");
        return;
    }
    const items = await readCommentsIndex();
    const index = items.findIndex((item) => item?.id === safeId && !item.deletedAt);
    if (index < 0) {
        sendText(res, 404, "Comment not found");
        return;
    }
    const comment = items[index];
    const isOwner = comment?.createdBy?.id && comment.createdBy.id === user.id;
    const now = nowIso();
    if (payload?.delete === true) {
        if (!isOwner) {
            sendJSON(res, 403, {ok: false, error: "comment_owner_required"});
            return;
        }
        comment.deletedAt = now;
        comment.updatedAt = now;
        await writeCommentsIndex(items);
        sendJSON(res, 200, {ok: true, deleted: true, id: safeId});
        return;
    }
    const hasTitle = Object.prototype.hasOwnProperty.call(payload, "title");
    const hasBody = Object.prototype.hasOwnProperty.call(payload, "body");
    const hasSubitems = Object.prototype.hasOwnProperty.call(payload, "subitems");
    const nextTitle = hasTitle ? sanitizeCommentText(payload.title, 180) || comment.title || "Комментарий" : comment.title;
    const nextBody = hasBody ? sanitizeCommentText(payload.body, 8000) : comment.body;
    const nextSubitems = hasSubitems ? sanitizeCommentSubitems(payload.subitems) : (Array.isArray(comment.subitems) ? comment.subitems : []);
    const titleChanged = hasTitle && nextTitle !== comment.title;
    const bodyChanged = hasBody && nextBody !== comment.body;
    const subitemsChanged = hasSubitems && JSON.stringify(nextSubitems) !== JSON.stringify(comment.subitems || []);
    if ((titleChanged || bodyChanged || subitemsChanged) && !isOwner) {
        sendJSON(res, 403, {ok: false, error: "comment_owner_required"});
        return;
    }
    if (titleChanged) {
        comment.title = nextTitle;
    }
    if (bodyChanged) {
        comment.body = nextBody;
    }
    if (subitemsChanged) {
        comment.subitems = nextSubitems;
    }
    const nextMessage = buildCommentMessage(payload?.message || null, user);
    if (nextMessage) {
        comment.messages = Array.isArray(comment.messages) ? comment.messages : [];
        comment.messages.push(nextMessage);
    }
    comment.updatedAt = now;
    items[index] = comment;
    await writeCommentsIndex(items);
    sendJSON(res, 200, {ok: true, comment: publicComment(comment)});
};

const requireModelRegistryUser = (req, res) => {
    const user = getRequestRegistryUser(req);
    if (!user) {
        sendJSON(res, 401, {ok: false, error: "bim_auth_required"});
        return null;
    }
    return user;
};

const handleGetModels = async (req, res) => {
    const user = requireModelRegistryUser(req, res);
    if (!user) {
        return;
    }
    const registry = await readModelRegistry();
    const assetsByKey = new Map(registry.assets.map((asset) => [asset?.assetKey, asset]).filter(([key]) => !!key));
    const models = registry.refs
        .filter((ref) => ref?.user?.id === user.id && !ref.deletedAt)
        .map((ref) => {
            const asset = assetsByKey.get(ref.assetKey) || {
                assetKey: ref.assetKey,
                assetId: ref.assetId,
                projectId: ref.projectId,
                sourceSrc: ref.settingsSrc || ref.src || null,
                downloadSrc: ref.src || ref.settingsSrc || null,
                originalFilename: ref.src ? path.basename(ref.src) : ref.assetId,
                type: guessViewerTypeFromSrc(ref.src || ref.settingsSrc || "", null),
                status: "unknown"
            };
            return buildModelListItem(asset, ref, registry);
        })
        .toSorted((first, second) => String(second.updatedAt || "").localeCompare(String(first.updatedAt || "")));
    sendJSON(res, 200, {
        ok: true,
        user: summarizeRegistryUser(user),
        models
    });
};

const handleCreateModelRef = async (req, res) => {
    const user = requireModelRegistryUser(req, res);
    if (!user) {
        return;
    }
    let payload = {};
    try {
        payload = await parseJSONBody(req);
    } catch (err) {
        sendText(res, 400, err.message || "Invalid JSON");
        return;
    }
    const src = normalizeUploadSrcValue(payload?.src || payload?.url || payload?.artifactSrc);
    const settingsSrc = normalizeUploadSrcValue(payload?.settingsSrc || payload?.sourceSrc || payload?.originalSrc) || src;
    const identity = resolveAssetIdentityFromPayload({...payload, src, settingsSrc});
    if (!identity) {
        sendText(res, 400, "Model ref must point to a BIM upload asset.");
        return;
    }
    const result = await mutateModelRegistry(async (registry) => {
        const name = stringOrNull(payload?.name) || (src ? path.basename(src) : identity.assetId);
        const asset = identity.legacy
            ? upsertRegistryAsset(registry, {
                ...identity,
                originalFilename: name,
                sourceSrc: settingsSrc,
                downloadSrc: src,
                type: stringOrNull(payload?.type) || guessViewerTypeFromSrc(src || settingsSrc || "", "file"),
                status: "ready",
                currentVersionId: stringOrNull(payload?.versionId)
            })
            : await upsertRegistryAssetFromManifest(registry, identity);
        const normalizedAsset = asset || upsertRegistryAsset(registry, {
            ...identity,
            originalFilename: name,
            sourceSrc: settingsSrc,
            downloadSrc: src,
            type: stringOrNull(payload?.type) || guessViewerTypeFromSrc(src || settingsSrc || "", "file"),
            status: "ready",
            currentVersionId: stringOrNull(payload?.versionId)
        });
        const ref = upsertRegistryUserRef(registry, identity, user, {
            kind: payload?.kind === "owner" ? "owner" : "saved",
            source: stringOrNull(payload?.source) || "manual",
            versionId: stringOrNull(payload?.versionId),
            src,
            settingsSrc,
            touch: true
        });
        return {asset: normalizedAsset, ref};
    });
    sendJSON(res, 201, {
        ok: true,
        model: buildModelListItem(result.asset, result.ref, {shares: [], refs: []})
    });
};

const handleDeleteModelRef = async (req, res) => {
    const user = requireModelRegistryUser(req, res);
    if (!user) {
        return;
    }
    let payload = {};
    try {
        payload = await parseJSONBody(req);
    } catch (err) {
        sendText(res, 400, err.message || "Invalid JSON");
        return;
    }
    const identity = resolveAssetIdentityFromPayload(payload);
    if (!identity) {
        sendText(res, 400, "Model ref must identify a BIM upload asset.");
        return;
    }
    const decision = await mutateModelRegistry(async (registry) => {
        const removedRefs = markRegistryUserRefsDeleted(registry, identity, user);
        const activeRefs = getActiveRegistryRefs(registry, identity);
        const summary = summarizeActiveRegistryRefs(activeRefs);
        return {
            canRemovePhysicalAsset: summary.total === 0,
            removedUserRefs: removedRefs.length,
            activeRefs: summary
        };
    });

    if (!decision.canRemovePhysicalAsset) {
        sendJSON(res, 200, {
            ok: true,
            deleted: true,
            removedAsset: false,
            removedUserRefs: decision.removedUserRefs,
            retainedByRefs: true,
            activeRefs: decision.activeRefs
        });
        return;
    }

    const assetDir = path.join(UPLOADS_DIR, identity.projectId, identity.assetId);
    const deletedFiles = [];
    if (!identity.legacy && isPathInsideOrEqual(assetDir, UPLOADS_DIR)) {
        try {
            const stats = await fs.stat(assetDir);
            if (stats.isDirectory()) {
                await fs.rm(assetDir, {recursive: true, force: true});
                await removeEmptyDirsUpTo(path.dirname(assetDir), UPLOADS_DIR);
            }
        } catch (_) {
            // idempotent cleanup
        }
    } else {
        deletedFiles.push(...await deleteUploadSrcsWithLocalManifestScope(collectUploadSrcValues(payload)));
    }
    await mutateModelRegistry(async (registry) => {
        markRegistryAssetPhysicallyDeleted(registry, identity);
    });
    sendJSON(res, 200, {
        ok: true,
        deleted: true,
        deletedFiles,
        removedAsset: !identity.legacy,
        removedUserRefs: decision.removedUserRefs,
        retainedByRefs: false
    });
};

const findManifestForModelSrc = async (src) => {
    const uploadPath = resolveUploadSrc(src);
    if (!uploadPath) {
        return null;
    }
    const sourceSrc = srcFromUploadPath(uploadPath);
    const sourceFormat = CONVERTIBLE_MODEL_FORMATS.get(path.extname(uploadPath).toLowerCase());
    if (sourceFormat) {
        return {
            manifestPath: manifestPathForSource(uploadPath),
            modelPath: uploadPath,
            sourceSrc
        };
    }

    const directManifestPath = manifestPathForSource(uploadPath);
    if (existsSync(directManifestPath)) {
        return {
            manifestPath: directManifestPath,
            modelPath: uploadPath,
            sourceSrc
        };
    }

    let entries = [];
    try {
        entries = await fs.readdir(path.dirname(uploadPath));
    } catch (_) {
        return {
            manifestPath: directManifestPath,
            modelPath: uploadPath,
            sourceSrc
        };
    }

    for (const entry of entries) {
        if (!entry.endsWith(".beam.json")) {
            continue;
        }
        const candidatePath = path.join(path.dirname(uploadPath), entry);
        const manifest = await readJSONFile(candidatePath);
        if (!manifest || typeof manifest !== "object") {
            continue;
        }
        const relatedSrcs = [
            manifest.sourceSrc,
            manifest.artifactSrc,
            manifest.fallbackArtifactSrc,
            manifest.metadataSrc
        ].map(normalizeUploadSrcValue).filter(Boolean);
        if (relatedSrcs.includes(sourceSrc)) {
            return {
                manifestPath: candidatePath,
                modelPath: uploadPath,
                sourceSrc: normalizeUploadSrcValue(manifest.sourceSrc) || sourceSrc
            };
        }
    }

    return {
        manifestPath: directManifestPath,
        modelPath: uploadPath,
        sourceSrc
    };
};

const readJSONFile = async (filePath) => {
    try {
        const raw = await fs.readFile(filePath, "utf8");
        return JSON.parse(raw);
    } catch (_) {
        return null;
    }
};

const writeJSONFile = async (filePath, payload) => {
    await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
};

const processUploads = async (project) => {
    if (!Array.isArray(project.models)) {
        return;
    }
    const projectDir = path.join(UPLOADS_DIR, project.id);
    await fs.mkdir(projectDir, {recursive: true});
    for (let i = 0; i < project.models.length; i++) {
        const model = project.models[i];
        if (!model || !model.srcData) {
            continue;
        }
        const data = decodeBase64(model.srcData);
        if (!data) {
            throw new Error("Invalid srcData for model upload");
        }
        const ext = path.extname(model.srcFilename || "");
        const baseName = sanitizeFilename(model.srcFilename || `${model.id || "model"}${ext || ".bin"}`, `${model.id || "model"}.bin`);
        const filePath = path.join(projectDir, baseName);
        await fs.writeFile(filePath, data);
        model.src = path.join("uploads", project.id, baseName).replace(/\\/g, "/");
        delete model.srcData;
        delete model.srcFilename;
        delete model.srcMime;
    }
};

const handleRawUpload = async (req, res, url) => {
    const filenameParam = url.searchParams.get("filename");
    const filenameHeader = req.headers["x-filename"];
    const rawName = filenameParam || (Array.isArray(filenameHeader) ? filenameHeader[0] : filenameHeader) || "upload.bin";
    const safeName = sanitizeFilename(rawName, "upload.bin");
    // if client passes projectId in query, group uploads per task/work item
    const projectId = safeProjectId(url.searchParams.get("projectId"));
    const uploadId = projectId || (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2));
    const requestedAssetId = safeAssetId(url.searchParams.get("assetId"));
    const assetId = requestedAssetId || `model_${crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)}`;
    const assetDir = path.join(UPLOADS_DIR, uploadId, assetId);
    const existingAssetManifest = await readAssetManifest(assetDir);
    const requestedVersion = Number.parseInt(url.searchParams.get("version") || "", 10);
    const version = nextAssetVersionNumber(existingAssetManifest, requestedVersion);
    const versionId = buildVersionId(version);
    const versionDir = path.join(assetDir, versionId);
    await fs.mkdir(versionDir, {recursive: true});
    const targetPath = path.join(versionDir, safeName);
    const writeStream = createWriteStream(targetPath);
    try {
        await pipeline(req, writeStream);
    } catch (err) {
        console.error("[server] upload failed", err);
        sendText(res, 500, "Upload failed");
        return;
    }
    const uploadedAt = nowIso();
    const sha256 = await calculateFileSha256(targetPath);
    const src = path.join("uploads", uploadId, assetId, versionId, safeName).replace(/\\/g, "/");
    const sourceFormat = CONVERTIBLE_MODEL_FORMATS.get(path.extname(safeName).toLowerCase());
    const reusableOriginal = await findReusableOriginalBySha256({sha256, sourceFormat, targetPath});
    const sourceDedup = reusableOriginal
        ? {
            sourceDedupMode: await linkOrCopyUploadFile(reusableOriginal.sourcePath, targetPath),
            sourceDedupedFromSourceSrc: reusableOriginal.sourceSrc
        }
        : null;
    const fileStats = await fs.stat(targetPath);
    const versionRecord = {
        assetId,
        projectId: uploadId,
        version,
        versionId,
        originalFilename: rawName,
        storedFilename: safeName,
        size: fileStats.size,
        sha256,
        sourceSrc: src,
        downloadSrc: src,
        sourceFormat: sourceFormat || path.extname(safeName).replace(/^\./, "").toLowerCase() || "file",
        status: sourceFormat ? "conversion_required" : "ready",
        targetFormat: sourceFormat ? "xkt" : null,
        uploadedAt,
        ...(sourceDedup || {})
    };

    if (sourceFormat) {
        const conversion = {
            ...versionRecord,
            componentTreeRequired: true,
            message: "Оригинальный STEP загружен. Просмотр появится после подготовки модели и дерева компонентов.",
            sourceFormat,
            status: "conversion_required",
            targetFormat: "xkt",
            updatedAt: uploadedAt
        };
        await writeJSONFile(manifestPathForSource(targetPath), conversion);
        await upsertAssetVersion(assetDir, {...versionRecord, message: conversion.message});
        await mutateModelRegistry(async (registry) => {
            const asset = upsertRegistryAsset(registry, registryAssetFromVersionRecord({...versionRecord, message: conversion.message}));
            upsertRegistryUserRef(registry, asset, getRequestRegistryUser(req), {
                kind: "owner",
                source: "upload",
                versionId,
                src,
                settingsSrc: src
            });
        });
        sendJSON(res, 201, {
            assetId,
            conversion,
            originalFilename: rawName,
            sha256,
            src,
            uploadedAt,
            version,
            versionId
        });
        return;
    }

    await writeJSONFile(manifestPathForSource(targetPath), {
        ...versionRecord,
        message: "Model is ready.",
        updatedAt: uploadedAt
    });
    await upsertAssetVersion(assetDir, versionRecord);
    await mutateModelRegistry(async (registry) => {
        const asset = upsertRegistryAsset(registry, registryAssetFromVersionRecord(versionRecord));
        upsertRegistryUserRef(registry, asset, getRequestRegistryUser(req), {
            kind: "owner",
            source: "upload",
            versionId,
            src,
            settingsSrc: src
        });
    });
    sendJSON(res, 201, {
        assetId,
        originalFilename: rawName,
        sha256,
        src,
        uploadedAt,
        version,
        versionId
    });
};

const handleDeleteUploadAsset = async (req, res) => {
    let payload = {};
    try {
        payload = await parseJSONBody(req);
    } catch (err) {
        sendText(res, 400, err.message || "Invalid JSON");
        return;
    }

    const deletedFiles = [];
    const assetDir = resolveAssetDirFromPayload(payload);
    const identity = resolveAssetIdentityFromPayload(payload) || getAssetIdentityFromAssetDir(assetDir);
    const user = getRequestRegistryUser(req);
    let registryDecision = null;

    if (identity) {
        registryDecision = await mutateModelRegistry(async (registry) => {
            if (identity.legacy) {
                const srcs = collectUploadSrcValues(payload);
                const firstSrc = [...srcs][0] || null;
                upsertRegistryAsset(registry, {
                    ...identity,
                    originalFilename: firstSrc ? path.basename(firstSrc) : null,
                    sourceSrc: firstSrc,
                    downloadSrc: firstSrc,
                    type: guessViewerTypeFromSrc(firstSrc || "", "file"),
                    status: "ready"
                });
            } else {
                await upsertRegistryAssetFromManifest(registry, identity);
            }
            const removedRefs = markRegistryUserRefsDeleted(registry, identity, user);
            const activeRefs = getActiveRegistryRefs(registry, identity);
            const summary = summarizeActiveRegistryRefs(activeRefs);
            return {
                canRemovePhysicalAsset: summary.total === 0,
                removedUserRefs: removedRefs.length,
                activeRefs: summary
            };
        });

        if (!registryDecision.canRemovePhysicalAsset) {
            sendJSON(res, 200, {
                deleted: true,
                deletedFiles,
                removedAsset: false,
                removedUserRefs: registryDecision.removedUserRefs,
                retainedByRefs: true,
                activeRefs: registryDecision.activeRefs
            });
            return;
        }
    }

    if (assetDir) {
        try {
            const stats = await fs.stat(assetDir);
            if (stats.isDirectory()) {
                await fs.rm(assetDir, {recursive: true, force: true});
                await removeEmptyDirsUpTo(path.dirname(assetDir), UPLOADS_DIR);
            }
        } catch (_) {
            // idempotent delete
        }
        if (identity) {
            await mutateModelRegistry(async (registry) => {
                markRegistryAssetPhysicallyDeleted(registry, identity);
            });
        }
        sendJSON(res, 200, {
            deleted: true,
            deletedFiles,
            removedAsset: true,
            removedUserRefs: registryDecision?.removedUserRefs || 0,
            retainedByRefs: false
        });
        return;
    }

    const fallbackSrcs = collectUploadSrcValues(payload);
    deletedFiles.push(...await deleteUploadSrcsWithLocalManifestScope(fallbackSrcs));
    if (identity) {
        await mutateModelRegistry(async (registry) => {
            markRegistryAssetPhysicallyDeleted(registry, identity);
        });
    }

    sendJSON(res, 200, {
        deleted: true,
        deletedFiles,
        removedAsset: false,
        removedUserRefs: registryDecision?.removedUserRefs || 0,
        retainedByRefs: false
    });
};

const handleDeleteUploadVersion = async (req, res) => {
    let payload = {};
    try {
        payload = await parseJSONBody(req);
    } catch (err) {
        sendText(res, 400, err.message || "Invalid JSON");
        return;
    }

    const assetDir = resolveAssetDirFromPayload(payload);
    const identity = resolveAssetIdentityFromPayload(payload) || getAssetIdentityFromAssetDir(assetDir);
    const user = getRequestRegistryUser(req);
    const requestedVersionId = typeof payload?.versionId === "string" ? payload.versionId : null;
    const requestedVersion = Number.parseInt(String(payload?.version || ""), 10);
    const requestedSrcs = collectUploadSrcValues(payload);

    if (!assetDir) {
        if (identity) {
            const decision = await mutateModelRegistry(async (registry) => {
                const activeRefs = getActiveRegistryRefsForVersion(registry, identity, {versionId: requestedVersionId}, requestedSrcs);
                return summarizeActiveRegistryRefs(activeRefs);
            });
            if (decision.total > 0) {
                sendJSON(res, 200, {
                    deleted: true,
                    deletedFiles: [],
                    removedAsset: false,
                    retainedByRefs: true,
                    activeRefs: decision
                });
                return;
            }
        }
        const deletedFiles = await deleteUploadSrcsWithLocalManifestScope(requestedSrcs);
        if (identity) {
            await mutateModelRegistry(async (registry) => {
                removeRegistryVersion(registry, identity, {versionId: requestedVersionId});
            });
        }
        sendJSON(res, 200, {
            deleted: true,
            deletedFiles,
            removedAsset: false
        });
        return;
    }

    const assetManifest = await readAssetManifest(assetDir);
    if (!assetManifest) {
        const expandedSrcs = new Set(requestedSrcs);
        for (const src of requestedSrcs) {
            const sourcePath = resolveUploadSrc(src);
            if (!sourcePath) continue;
            const manifest = await readJSONFile(manifestPathForSource(sourcePath));
            collectUploadSrcValues(manifest, expandedSrcs);
        }
        if (identity) {
            const decision = await mutateModelRegistry(async (registry) => {
                const activeRefs = getActiveRegistryRefsForVersion(registry, identity, {versionId: requestedVersionId}, expandedSrcs);
                return summarizeActiveRegistryRefs(activeRefs);
            });
            if (decision.total > 0) {
                sendJSON(res, 200, {
                    deleted: true,
                    deletedFiles: [],
                    removedAsset: false,
                    retainedByRefs: true,
                    activeRefs: decision
                });
                return;
            }
        }
        const deletedFiles = await deleteUploadSrcFiles(expandedSrcs, new Set(), assetDir, assetDir);
        if (identity) {
            await mutateModelRegistry(async (registry) => {
                removeRegistryVersion(registry, identity, {versionId: requestedVersionId});
            });
        }
        sendJSON(res, 200, {
            deleted: true,
            deletedFiles,
            removedAsset: false
        });
        return;
    }

    const versions = Array.isArray(assetManifest.versions) ? assetManifest.versions : [];
    const normalizedRequestedSrcs = new Set([...requestedSrcs].map(normalizeUploadSrcValue).filter(Boolean));
    const versionToDelete = versions.find((version) => {
        if (requestedVersionId && version?.versionId === requestedVersionId) {
            return true;
        }
        if (Number.isFinite(requestedVersion) && Number(version?.version) === requestedVersion) {
            return true;
        }
        const versionSrcs = collectUploadSrcValues(version);
        return [...versionSrcs].some((src) => normalizedRequestedSrcs.has(src));
    });

    if (!versionToDelete) {
        sendJSON(res, 200, {
            deleted: true,
            deletedFiles: [],
            removedAsset: false,
            versionMissing: true
        });
        return;
    }

    const remainingVersions = versions.filter((version) => version !== versionToDelete);
    if (remainingVersions.length === 0) {
        let registryDecision = null;
        if (identity) {
            registryDecision = await mutateModelRegistry(async (registry) => {
                await upsertRegistryAssetFromManifest(registry, identity);
                const removedRefs = markRegistryUserRefsDeleted(registry, identity, user);
                const activeRefs = getActiveRegistryRefs(registry, identity);
                const summary = summarizeActiveRegistryRefs(activeRefs);
                return {
                    canRemovePhysicalAsset: summary.total === 0,
                    removedUserRefs: removedRefs.length,
                    activeRefs: summary
                };
            });
            if (!registryDecision.canRemovePhysicalAsset) {
                sendJSON(res, 200, {
                    deleted: true,
                    deletedFiles: [],
                    removedAsset: false,
                    removedUserRefs: registryDecision.removedUserRefs,
                    retainedByRefs: true,
                    activeRefs: registryDecision.activeRefs,
                    versionId: versionToDelete.versionId
                });
                return;
            }
        }
        try {
            await fs.rm(assetDir, {recursive: true, force: true});
            await removeEmptyDirsUpTo(path.dirname(assetDir), UPLOADS_DIR);
        } catch (_) {
            // idempotent delete
        }
        if (identity) {
            await mutateModelRegistry(async (registry) => {
                markRegistryAssetPhysicallyDeleted(registry, identity);
            });
        }
        sendJSON(res, 200, {
            deleted: true,
            deletedFiles: [],
            removedAsset: true,
            removedUserRefs: registryDecision?.removedUserRefs || 0,
            retainedByRefs: false,
            versionId: versionToDelete.versionId
        });
        return;
    }

    const targetSrcs = await collectVersionUploadSrcs(versionToDelete);
    if (identity) {
        const registryDecision = await mutateModelRegistry(async (registry) => {
            await upsertRegistryAssetFromManifest(registry, identity);
            const activeRefs = getActiveRegistryRefsForVersion(registry, identity, versionToDelete, targetSrcs);
            const currentUserId = normalizeRegistryUser(user)?.id || null;
            const blockingUserRefs = activeRefs.userRefs.filter((ref) => !currentUserId || ref?.user?.id !== currentUserId);
            return summarizeActiveRegistryRefs({
                userRefs: blockingUserRefs,
                shareRefs: activeRefs.shareRefs
            });
        });
        if (registryDecision.total > 0) {
            sendJSON(res, 200, {
                deleted: true,
                deletedFiles: [],
                removedAsset: false,
                retainedByRefs: true,
                activeRefs: registryDecision,
                versionId: versionToDelete.versionId
            });
            return;
        }
    }
    const versionDir = getVersionDirFromVersionRecord(versionToDelete, assetDir);
    const protectedSrcs = new Set();
    for (const version of remainingVersions) {
        const versionSrcs = await collectVersionUploadSrcs(version);
        for (const src of versionSrcs) {
            protectedSrcs.add(src);
        }
    }

    const deletedFiles = await deleteUploadSrcFiles(targetSrcs, protectedSrcs, assetDir, versionDir);
    if (versionDir && versionToDelete?.versionId && path.basename(versionDir) === versionToDelete.versionId && isPathInsideOrEqual(versionDir, assetDir)) {
        try {
            await fs.rm(versionDir, {recursive: true, force: true});
        } catch (_) {
            // idempotent cleanup
        }
    }
    const deletedCurrent = assetManifest.currentVersionId
        ? assetManifest.currentVersionId === versionToDelete.versionId
        : Number(assetManifest.currentVersion) === Number(versionToDelete.version);
    const nextCurrent = deletedCurrent
        ? remainingVersions.toSorted((first, second) => Number(second.version || 0) - Number(first.version || 0))[0]
        : remainingVersions.find((version) => version.versionId === assetManifest.currentVersionId) || remainingVersions[remainingVersions.length - 1];
    const nextManifest = {
        ...assetManifest,
        updatedAt: nowIso(),
        currentVersion: nextCurrent?.version,
        currentVersionId: nextCurrent?.versionId,
        versions: remainingVersions
    };
    await writeAssetManifest(assetDir, nextManifest);
    if (identity) {
        await mutateModelRegistry(async (registry) => {
            removeRegistryVersion(registry, identity, versionToDelete);
            const currentUser = normalizeRegistryUser(user);
            if (currentUser) {
                for (const ref of registry.refs) {
                    if (ref?.assetKey !== identity.assetKey || ref?.user?.id !== currentUser.id || ref.deletedAt) {
                        continue;
                    }
                    if (ref.versionId === versionToDelete.versionId) {
                        ref.versionId = nextCurrent?.versionId || null;
                        ref.src = normalizeUploadSrcValue(nextCurrent?.downloadSrc || nextCurrent?.sourceSrc || nextCurrent?.src) || ref.src || null;
                        ref.settingsSrc = normalizeUploadSrcValue(nextCurrent?.sourceSrc || nextCurrent?.downloadSrc || nextCurrent?.src) || ref.settingsSrc || null;
                        ref.updatedAt = nowIso();
                    }
                }
            }
        });
    }

    sendJSON(res, 200, {
        deleted: true,
        deletedFiles,
        removedAsset: false,
        versionId: versionToDelete.versionId
    });
};

const handleConversionStatus = async (res, searchParams) => {
    const sourcePath = resolveUploadSrc(searchParams.get("src"));
    if (!sourcePath) {
        sendText(res, 400, "Invalid source path");
        return;
    }
    if (!existsSync(sourcePath)) {
        sendText(res, 404, "Source file not found");
        return;
    }

    const sourceFormat = CONVERTIBLE_MODEL_FORMATS.get(path.extname(sourcePath).toLowerCase());
    if (!sourceFormat) {
        sendJSON(res, 200, {
            sourceSrc: srcFromUploadPath(sourcePath),
            status: "ready"
        });
        return;
    }

    const manifest = await readJSONFile(manifestPathForSource(sourcePath));
    if (!manifest) {
        sendJSON(res, 200, {
            componentTreeRequired: true,
            message: "Оригинальный STEP загружен. Просмотр появится после подготовки модели и дерева компонентов.",
            sourceFormat,
            sourceSrc: srcFromUploadPath(sourcePath),
            status: "conversion_required",
            targetFormat: "xkt",
            updatedAt: nowIso()
        });
        return;
    }

    sendJSON(res, 200, manifest);
};

const handleGetModelSettings = async (res, searchParams) => {
    const manifestRef = await findManifestForModelSrc(searchParams.get("src"));
    if (!manifestRef) {
        sendText(res, 400, "Invalid model path");
        return;
    }
    if (!existsSync(manifestRef.modelPath)) {
        sendText(res, 404, "Model file not found");
        return;
    }

    const manifest = await readJSONFile(manifestRef.manifestPath);
    sendJSON(res, 200, {
        sourceSrc: manifest?.sourceSrc || manifestRef.sourceSrc,
        artifactSrc: manifest?.artifactSrc || null,
        fallbackArtifactSrc: manifest?.fallbackArtifactSrc || null,
        viewerSettings: manifest?.viewerSettings || null
    });
};

const handlePutModelSettings = async (req, res, searchParams) => {
    const manifestRef = await findManifestForModelSrc(searchParams.get("src"));
    if (!manifestRef) {
        sendText(res, 400, "Invalid model path");
        return;
    }
    if (!existsSync(manifestRef.modelPath)) {
        sendText(res, 404, "Model file not found");
        return;
    }

    let payload = {};
    try {
        payload = await parseJSONBody(req);
    } catch (err) {
        sendText(res, 400, err.message || "Invalid JSON");
        return;
    }

    const viewerSettings = payload?.viewerSettings;
    if (!viewerSettings || typeof viewerSettings !== "object" || Array.isArray(viewerSettings)) {
        sendText(res, 400, "viewerSettings object is required");
        return;
    }

    const existing = await readJSONFile(manifestRef.manifestPath) || {};
    const now = nowIso();
    const nextManifest = {
        ...existing,
        sourceSrc: existing.sourceSrc || manifestRef.sourceSrc,
        status: existing.status || "ready",
        viewerSettings: {
            ...viewerSettings,
            updatedAt: viewerSettings.updatedAt || now
        },
        viewerSettingsUpdatedAt: now,
        updatedAt: existing.updatedAt || now
    };

    try {
        await writeJSONFile(manifestRef.manifestPath, nextManifest);
        sendJSON(res, 200, {
            sourceSrc: nextManifest.sourceSrc,
            artifactSrc: nextManifest.artifactSrc || null,
            fallbackArtifactSrc: nextManifest.fallbackArtifactSrc || null,
            viewerSettings: nextManifest.viewerSettings
        });
    } catch (err) {
        console.error("[server] failed to save model settings", err);
        sendText(res, 500, "Failed to save model settings");
    }
};

const buildProjectPayload = (body) => {
    const now = new Date().toISOString();
    const id = `proj_${crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)}`;
    const name = typeof body.name === "string" ? body.name.trim() : "";
    if (!name) {
        throw new Error("Project name is required");
    }
    const type = body.type === "template" ? "template" : "project";
    const models = Array.isArray(body.models) ? body.models : [];
    const viewerState = body.viewerState && typeof body.viewerState === "object" ? body.viewerState : {};
    const design = body.design && typeof body.design === "object" ? body.design : {};
    const meta = body.meta && typeof body.meta === "object" ? body.meta : {description: "", tags: []};

    return {
        id,
        name,
        type,
        createdAt: now,
        updatedAt: now,
        ownerId: null,
        shares: [],
        models,
        viewerState,
        design,
        meta
    };
};

const buildUpdatedProject = (body, existing) => {
    const now = new Date().toISOString();
    const name = typeof body.name === "string" ? body.name.trim() : "";
    if (!name) {
        throw new Error("Project name is required");
    }
    const type = body.type === "template" ? "template" : (body.type === "project" ? "project" : (existing.type || "project"));
    const models = Array.isArray(body.models) ? body.models : (existing.models || []);
    const viewerState = body.viewerState && typeof body.viewerState === "object" ? body.viewerState : (existing.viewerState || {});
    const design = body.design && typeof body.design === "object" ? body.design : (existing.design || {});
    const meta = body.meta && typeof body.meta === "object" ? body.meta : (existing.meta || {description: "", tags: []});

    return {
        id: existing.id,
        name,
        type,
        createdAt: existing.createdAt || now,
        updatedAt: now,
        ownerId: existing.ownerId ?? null,
        shares: Array.isArray(existing.shares) ? existing.shares : [],
        models,
        viewerState,
        design,
        meta
    };
};

const handleGetProjects = async (res, searchParams) => {
    const filterType = searchParams.get("type");
    const items = await readIndex();
    const filtered = filterType ? items.filter((item) => item.type === filterType) : items;
    sendJSON(res, 200, filtered);
};

const handleGetProject = async (res, projectId) => {
    const safeId = safeProjectId(projectId);
    if (!safeId) {
        sendText(res, 400, "Invalid project id");
        return;
    }
    const projectPath = path.join(DATA_DIR, `${safeId}.json`);
    if (!existsSync(projectPath)) {
        sendText(res, 404, "Project not found");
        return;
    }
    try {
        const raw = await fs.readFile(projectPath, "utf8");
        const parsed = JSON.parse(raw);
        sendJSON(res, 200, parsed);
    } catch (err) {
        console.error("[server] failed to read project file", err);
        sendText(res, 500, "Failed to read project");
    }
};

const handleCreateProject = async (req, res) => {
    let payload;
    try {
        payload = await parseJSONBody(req);
    } catch (err) {
        sendText(res, 400, err.message || "Invalid request body");
        return;
    }
    try {
        const project = buildProjectPayload(payload);
        await processUploads(project);
        const projectPath = path.join(DATA_DIR, `${project.id}.json`);
        await fs.writeFile(projectPath, JSON.stringify(project, null, 2), "utf8");

        const indexItems = await readIndex();
        const summary = {
            id: project.id,
            name: project.name,
            type: project.type,
            ownerId: project.ownerId,
            createdAt: project.createdAt,
            updatedAt: project.updatedAt
        };
        const nextIndex = [summary, ...indexItems.filter((item) => item.id !== project.id)];
        await writeIndex(nextIndex);

        sendJSON(res, 201, project);
    } catch (err) {
        console.error("[server] failed to create project", err);
        sendText(res, 400, err.message || "Failed to create project");
    }
};

const handlePatchProject = async (req, res, projectId) => {
    const safeId = safeProjectId(projectId);
    if (!safeId) {
        sendText(res, 400, "Invalid project id");
        return;
    }
    const projectPath = path.join(DATA_DIR, `${safeId}.json`);
    if (!existsSync(projectPath)) {
        sendText(res, 404, "Project not found");
        return;
    }
    let payload = {};
    try {
        payload = await parseJSONBody(req);
    } catch (err) {
        sendText(res, 400, err.message || "Invalid JSON");
        return;
    }
    if (!payload || typeof payload !== "object") {
        sendText(res, 400, "Invalid payload");
        return;
    }
    try {
        const raw = await fs.readFile(projectPath, "utf8");
        const project = JSON.parse(raw);
        const now = new Date().toISOString();
        if (typeof payload.name === "string") {
            project.name = payload.name.trim();
        }
        if (payload.type === "template" || payload.type === "project") {
            project.type = payload.type;
        }
        project.updatedAt = now;
        await fs.writeFile(projectPath, JSON.stringify(project, null, 2), "utf8");

        const items = await readIndex();
        const nextIndex = items.map((item) => {
            if (item.id !== safeId) return item;
            return {
                ...item,
                name: project.name,
                type: project.type,
                updatedAt: project.updatedAt
            };
        });
        await writeIndex(nextIndex);
        sendJSON(res, 200, project);
    } catch (err) {
        console.error("[server] failed to patch project", err);
        sendText(res, 500, "Failed to update project");
    }
};

const handlePutProject = async (req, res, projectId) => {
    const safeId = safeProjectId(projectId);
    if (!safeId) {
        sendText(res, 400, "Invalid project id");
        return;
    }
    const projectPath = path.join(DATA_DIR, `${safeId}.json`);
    if (!existsSync(projectPath)) {
        sendText(res, 404, "Project not found");
        return;
    }
    let payload = {};
    try {
        payload = await parseJSONBody(req);
    } catch (err) {
        sendText(res, 400, err.message || "Invalid JSON");
        return;
    }
    try {
        const raw = await fs.readFile(projectPath, "utf8");
        const existing = JSON.parse(raw);
        const updated = buildUpdatedProject(payload, { ...existing, id: safeId });
        await fs.writeFile(projectPath, JSON.stringify(updated, null, 2), "utf8");
        const items = await readIndex();
        const nextIndex = items.map((item) => {
            if (item.id !== safeId) return item;
            return {
                ...item,
                name: updated.name,
                type: updated.type,
                updatedAt: updated.updatedAt
            };
        });
        await writeIndex(nextIndex);
        sendJSON(res, 200, updated);
    } catch (err) {
        console.error("[server] failed to save project", err);
        sendText(res, 500, "Failed to save project");
    }
};

const cleanupProjectUploadsForUser = async (req, projectId) => {
    const safeId = safeProjectId(projectId);
    if (!safeId) {
        return {removedAssets: 0, retainedAssets: 0, deletedFiles: []};
    }
    const uploadDir = path.join(UPLOADS_DIR, safeId);
    const user = getRequestRegistryUser(req);
    const result = {
        removedAssets: 0,
        retainedAssets: 0,
        deletedFiles: []
    };
    let entries = [];
    try {
        entries = await fs.readdir(uploadDir, {withFileTypes: true});
    } catch (_) {
        return result;
    }

    for (const entry of entries) {
        const entryPath = path.join(uploadDir, entry.name);
        const identity = entry.isDirectory()
            ? normalizeAssetIdentity({projectId: safeId, assetId: entry.name})
            : getAssetIdentityFromUploadPath(entryPath);
        if (!identity) {
            continue;
        }
        const decision = await mutateModelRegistry(async (registry) => {
            if (entry.isDirectory() && !identity.legacy) {
                await upsertRegistryAssetFromManifest(registry, identity);
            } else {
                const src = srcFromUploadPath(entryPath);
                upsertRegistryAsset(registry, {
                    ...identity,
                    originalFilename: entry.name,
                    sourceSrc: src,
                    downloadSrc: src,
                    type: guessViewerTypeFromSrc(src, "file"),
                    status: "ready"
                });
            }
            markRegistryUserRefsDeleted(registry, identity, user);
            return summarizeActiveRegistryRefs(getActiveRegistryRefs(registry, identity));
        });

        if (decision.total > 0) {
            result.retainedAssets += 1;
            continue;
        }

        if (entry.isDirectory()) {
            try {
                await fs.rm(entryPath, {recursive: true, force: true});
                result.removedAssets += 1;
            } catch (_) {
                // idempotent cleanup
            }
        } else if (entry.isFile()) {
            const src = srcFromUploadPath(entryPath);
            result.deletedFiles.push(...await deleteUploadSrcsWithLocalManifestScope(new Set([src])));
        }
        await mutateModelRegistry(async (registry) => {
            markRegistryAssetPhysicallyDeleted(registry, identity);
        });
    }
    await removeEmptyDirsUpTo(uploadDir, UPLOADS_DIR);
    return result;
};

const handleDeleteProject = async (req, res, projectId) => {
    const safeId = safeProjectId(projectId);
    if (!safeId) {
        sendText(res, 400, "Invalid project id");
        return;
    }
    const projectPath = path.join(DATA_DIR, `${safeId}.json`);
    if (!existsSync(projectPath)) {
        sendText(res, 404, "Project not found");
        return;
    }
    try {
        await fs.unlink(projectPath);
    } catch (err) {
        console.error("[server] failed to delete project file", err);
    }
    const uploadCleanup = await cleanupProjectUploadsForUser(req, safeId);
    const items = await readIndex();
    const filtered = items.filter((item) => item.id !== safeId);
    await writeIndex(filtered);
    sendJSON(res, 200, {deleted: true, id: safeId, uploadCleanup});
};

const requestHandler = async (req, res) => {
    const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
    setCors(req, res);
    if (req.method === "GET" && url.pathname === "/") {
        console.log("[viewer-request]", truncateLogValue(`${url.pathname}${url.search}`));
    }

    if (req.method === "OPTIONS") {
        setCors(res);
        res.statusCode = 204;
        res.end();
        return;
    }

    if (req.method === "POST" && url.pathname === "/api/client-log") {
        const payload = await parseJSONBody(req);
        console.log(
            "[viewer-client]",
            payload?.level || "info",
            payload?.event || "event",
            truncateLogValue(payload || {})
        );
        sendJSON(res, 200, {ok: true});
        return;
    }

    if (req.method === "GET" && url.pathname === "/api/auth/session") {
        return handleAuthSession(req, res);
    }

    if (req.method === "GET" && url.pathname === "/auth/nodedc/handoff") {
        return handleLauncherHandoff(req, res, url);
    }

    if (req.method === "GET" && url.pathname === "/auth/logout") {
        return handleLogout(req, res);
    }

    if (req.method === "GET" && /^\/api\/shares\/[^/]+\/?$/.test(url.pathname)) {
        return handleGetShare(req, res, shareTokenFromPath(url.pathname));
    }

    if (ensureBimSessionForRequest(req, res, url)) {
        return;
    }

    if (req.method === "POST" && url.pathname === "/api/shares") {
        return handleCreateShare(req, res);
    }

    if (url.pathname.startsWith("/api/comments")) {
        if (req.method === "GET" && url.pathname === "/api/comments") {
            return handleGetComments(req, res, url);
        }
        if (req.method === "POST" && url.pathname === "/api/comments") {
            return handleCreateComment(req, res);
        }
        if (req.method === "PATCH" && /^\/api\/comments\/[^/]+\/?$/.test(url.pathname)) {
            return handlePatchComment(req, res, url.pathname.split("/")[3]);
        }
        sendText(res, 405, "Method Not Allowed");
        return;
    }

    if (url.pathname.startsWith("/api/models")) {
        if (req.method === "GET" && url.pathname === "/api/models") {
            return handleGetModels(req, res);
        }
        if (req.method === "POST" && url.pathname === "/api/models/refs") {
            return handleCreateModelRef(req, res);
        }
        if (req.method === "DELETE" && url.pathname === "/api/models/refs") {
            return handleDeleteModelRef(req, res);
        }
        sendText(res, 405, "Method Not Allowed");
        return;
    }

    if (url.pathname.startsWith("/api/projects")) {
        if (req.method === "GET" && url.pathname === "/api/projects") {
            return handleGetProjects(res, url.searchParams);
        }
        if (req.method === "GET" && /^\/api\/projects\/[^/]+/.test(url.pathname)) {
            const projectId = url.pathname.split("/")[3];
            return handleGetProject(res, projectId);
        }
        if (req.method === "POST" && url.pathname === "/api/projects") {
            return handleCreateProject(req, res);
        }
        if (req.method === "PATCH" && /^\/api\/projects\/[^/]+/.test(url.pathname)) {
            const projectId = url.pathname.split("/")[3];
            return handlePatchProject(req, res, projectId);
        }
        if (req.method === "PUT" && /^\/api\/projects\/[^/]+/.test(url.pathname)) {
            const projectId = url.pathname.split("/")[3];
            return handlePutProject(req, res, projectId);
        }
        if (req.method === "DELETE" && /^\/api\/projects\/[^/]+/.test(url.pathname)) {
            const projectId = url.pathname.split("/")[3];
            return handleDeleteProject(req, res, projectId);
        }
        sendText(res, 405, "Method Not Allowed");
        return;
    }

    if (req.method === "POST" && url.pathname === "/api/uploads") {
        return handleRawUpload(req, res, url);
    }

    if (req.method === "DELETE" && url.pathname === "/api/uploads/asset") {
        return handleDeleteUploadAsset(req, res);
    }

    if (req.method === "DELETE" && url.pathname === "/api/uploads/version") {
        return handleDeleteUploadVersion(req, res);
    }

    if (req.method === "GET" && url.pathname === "/api/conversions/status") {
        return handleConversionStatus(res, url.searchParams);
    }

    if (req.method === "GET" && url.pathname === "/api/model-settings") {
        return handleGetModelSettings(res, url.searchParams);
    }

    if (req.method === "PUT" && url.pathname === "/api/model-settings") {
        return handlePutModelSettings(req, res, url.searchParams);
    }

    await serveStatic(req, res, url);
};

const start = async () => {
    await ensureStorage();

    const startServer = (port, retries = 3) => {
        const server = http.createServer((req, res) => {
            requestHandler(req, res).catch((err) => {
                console.error("[server] unhandled error", err);
                sendText(res, 500, "Internal Server Error");
            });
        });

        server.on("error", (err) => {
            if (err.code === "EADDRINUSE" && retries > 0) {
                const nextPort = port + 1;
                console.warn(`[server] port ${port} is busy, trying ${nextPort}...`);
                startServer(nextPort, retries - 1);
            } else {
                console.error("[server] failed to start", err);
                process.exit(1);
            }
        });

        server.listen(port, () => {
            console.log(`[server] listening on http://localhost:${port}`);
        });
    };

    startServer(PORT);
};

start().catch((err) => {
    console.error("[server] failed to start", err);
    process.exit(1);
});
