import { createHash } from "node:crypto";
import { createReadStream, createWriteStream, existsSync } from "node:fs";
import { mkdir, readFile, readdir, rename, stat, unlink } from "node:fs/promises";
import { once } from "node:events";
import { dirname, extname, join, resolve } from "node:path";
import sharp from "sharp";
import { resolveCapturedAt } from "./capture-date.js";
const THUMB_MAX_EDGE = 1024;
/** Extension déduite du nom d'origine, sinon du mime (formats connus). */
const MIME_EXT = {
    "image/jpeg": ".jpg",
    "image/png": ".png",
    "image/webp": ".webp",
    "image/heic": ".heic",
    "image/heif": ".heif",
    "image/gif": ".gif",
    "video/mp4": ".mp4",
    "video/quicktime": ".mov",
    "video/x-msvideo": ".avi",
};
/**
 * Connecteur de stockage local — les octets vivent sur le disque du serveur.
 *
 * Layout (sous DATA_DIR) :
 *   media/<familyId>/<mediaId><ext>        originaux
 *   thumbs/<familyId>/<mediaId>_<size>.jpg miniatures générées (cache)
 */
export class LocalStorageConnector {
    root;
    constructor(opts) {
        this.root = resolve(opts.dataDir);
    }
    /** Chemin absolu d'un fichier média relatif. */
    absolutePath(familyId, relativePath) {
        return join(this.root, "media", familyId, relativePath);
    }
    /** Chemin absolu d'un fichier de miniature en cache. */
    thumbPath(familyId, mediaId, size) {
        return join(this.root, "thumbs", familyId, `${mediaId}_${size}.jpg`);
    }
    /**
     * Écrit le fichier sur disque et calcule les métadonnées (taille, etag,
     * dimensions, date de capture). L'extension est déduite du nom d'origine
     * puis du mime.
     */
    async upload(input) {
        const familyDir = join(this.root, "media", input.familyId);
        await mkdir(familyDir, { recursive: true });
        const ext = sanitizeExt(extname(input.name)) ?? MIME_EXT[input.mime] ?? ".bin";
        const relativePath = `${input.mediaId}${ext}`;
        const abs = join(familyDir, relativePath);
        // Écriture vers un fichier temporaire + hash sha256 en streaming (un
        // seul passage), puis renommage atomique — jamais de fichier à moitié
        // écrit visible.
        const tmp = `${abs}.tmp`;
        const hash = createHash("sha256");
        const ws = createWriteStream(tmp);
        for await (const chunk of input.stream) {
            hash.update(chunk);
            if (!ws.write(chunk))
                await once(ws, "drain");
        }
        ws.end();
        await once(ws, "finish");
        await rename(tmp, abs);
        const st = await stat(abs);
        // Date de capture : client → nom de fichier → EXIF → mtime.
        let capturedAt = input.capturedAt;
        let source = "mtime";
        if (capturedAt && !Number.isNaN(capturedAt.getTime())) {
            source = "client";
        }
        else {
            const resolution = await resolveCapturedAt(input.name, st.mtime, async () => {
                // Lecture du début du fichier pour l'EXIF JPEG (128 Ko suffisent).
                const head = await readFile(abs);
                return head.subarray(0, 128 * 1024);
            });
            capturedAt = resolution.date;
            source = resolution.source;
        }
        // Dimensions + possibilité de miniature (images uniquement).
        let width = null;
        let height = null;
        let hasPreview = false;
        if (input.mime.startsWith("image/")) {
            try {
                const meta = await sharp(abs, { failOn: "none" }).metadata();
                width = meta.width ?? null;
                height = meta.height ?? null;
                // Heic/Heif demandent des libs natives sharp — on tente, on échoue
                // proprement si indisponible.
                hasPreview = !["heic", "heif"].includes(meta.format ?? "");
            }
            catch {
                /* métadonnées illisibles — largeur/hauteur nulles, pas de miniature */
            }
        }
        return {
            path: relativePath,
            size: st.size,
            etag: hash.digest("hex"),
            width,
            height,
            hasPreview,
            capturedAt,
            capturedAtSource: source,
        };
    }
    /**
     * Miniature JPEG : cache disque, générée à la demande depuis l'original.
     * Retourne null si le média n'a pas de miniature (vidéo, format non
     * décodable).
     */
    async getThumb(familyId, mediaId, relativePath, size) {
        const cached = this.thumbPath(familyId, mediaId, size);
        if (existsSync(cached)) {
            return readFile(cached);
        }
        const abs = this.absolutePath(familyId, relativePath);
        if (!existsSync(abs))
            return null;
        const clamped = Math.min(Math.max(size, 16), THUMB_MAX_EDGE);
        let buf;
        try {
            buf = await sharp(abs, { failOn: "none" })
                .rotate() // respecte l'orientation EXIF
                .resize(clamped, clamped, { fit: "inside", withoutEnlargement: true })
                .jpeg({ quality: 80 })
                .toBuffer();
        }
        catch {
            return null; // média non décodable (vidéo…) — pas de miniature
        }
        await mkdir(dirname(cached), { recursive: true });
        await writeFileAtomic(cached, buf);
        return buf;
    }
    /** Purge toutes les tailles de miniatures d'un média (après édition). */
    async purgeThumbs(familyId, mediaId) {
        const dir = join(this.root, "thumbs", familyId);
        let names;
        try {
            names = await readdir(dir);
        }
        catch {
            return; // rien à purger
        }
        const prefix = `${mediaId}_`;
        await Promise.all(names
            .filter((n) => n.startsWith(prefix))
            .map((n) => unlink(join(dir, n)).catch(() => { })));
    }
    /** Re-calcule size/etag depuis le disque (après édition en place). */
    async refreshMetadata(familyId, relativePath) {
        const abs = this.absolutePath(familyId, relativePath);
        const hash = createHash("sha256");
        const data = await readFile(abs);
        hash.update(data);
        const st = await stat(abs);
        return { size: st.size, etag: hash.digest("hex") };
    }
    /** Supprime l'original + les miniatures en cache. */
    async deleteMedia(familyId, relativePath, mediaId) {
        await unlink(this.absolutePath(familyId, relativePath)).catch(() => { });
        await this.purgeThumbs(familyId, mediaId);
    }
    /**
     * Flux de lecture avec support des requêtes Range (vidéo).
     * Retourne le flux + la taille totale.
     */
    async openOriginal(familyId, relativePath, range) {
        const abs = this.absolutePath(familyId, relativePath);
        const st = await stat(abs);
        if (!range) {
            return { stream: createReadStream(abs), size: st.size };
        }
        const start = Math.max(range.start, 0);
        const end = Math.min(range.end, st.size - 1);
        if (start > end || start >= st.size) {
            return { stream: null, size: st.size };
        }
        return {
            stream: createReadStream(abs, { start, end }),
            size: st.size,
            start,
            end,
        };
    }
}
/** "image/jpeg" → ".jpg" ; nettoie les extensions bizarres. */
function sanitizeExt(ext) {
    if (!ext || ext.length > 10 || !/^\.[A-Za-z0-9]+$/.test(ext))
        return null;
    return ext.toLowerCase();
}
async function writeFileAtomic(path, data) {
    const tmp = `${path}.tmp`;
    await new Promise((resolveWrite, reject) => {
        const ws = createWriteStream(tmp);
        ws.on("error", reject);
        ws.on("finish", () => resolveWrite());
        ws.end(data);
    });
    await rename(tmp, path);
}
//# sourceMappingURL=LocalStorageConnector.js.map