import { randomUUID } from "node:crypto";
import { and, desc, eq, isNull, lt, sql } from "drizzle-orm";
import { z } from "zod";
import { mediaCaptionUpdateSchema, mediaListQuerySchema, } from "@familyalbum/shared";
import { ApiError, badRequest, notFound } from "../../../core/errors.js";
import { db } from "../../../core/db/client.js";
import { buildAuthHooks } from "../../../core/plugins/auth.js";
import { jsonSchema } from "../../../core/lib/json-schema.js";
import { buildFamilyAuthHooks } from "../plugins/auth.js";
import { media } from "../db/schema.js";
const THUMB_DEFAULT = 256;
const THUMB_MAX = 1024;
/** Réutilisé par les routes albums (grille d'un album). */
export function serializeMedia(m) {
    return {
        id: m.id,
        familyId: m.familyId,
        path: m.path,
        name: m.name,
        etag: m.etag,
        size: m.size,
        mime: m.mime,
        capturedAt: m.capturedAt.toISOString(),
        capturedAtSource: m.capturedAtSource,
        width: m.width,
        height: m.height,
        durationMs: m.durationMs,
        hasPreview: m.hasPreview,
        uploadedBy: m.uploadedBy,
        caption: m.caption,
        updatedAt: m.updatedAt.toISOString(),
    };
}
/** Parse "bytes=start-end" / "bytes=start-" / "bytes=-suffix". */
function parseRange(header, size) {
    const m = header.match(/^bytes=(\d*)-(\d*)$/);
    if (!m)
        return null;
    const startStr = m[1] ?? "";
    const endStr = m[2] ?? "";
    if (startStr === "" && endStr === "")
        return null;
    if (startStr === "") {
        // suffixe : les N derniers octets
        const suffix = Number(endStr);
        if (!Number.isFinite(suffix) || suffix <= 0)
            return null;
        const start = Math.max(size - suffix, 0);
        return { start, end: size - 1 };
    }
    const start = Number(startStr);
    if (!Number.isFinite(start) || start < 0)
        return null;
    if (endStr === "") {
        // Range ouverte : si le début dépasse la taille, on la laisse passer
        // (end = taille-1) — openOriginal la jugera insatisfaisable (416, RFC 7233).
        return { start, end: size - 1 };
    }
    const end = Number(endStr);
    if (!Number.isFinite(end) || end < start)
        return null;
    return { start, end };
}
export function registerMediaRoutes(app, opts) {
    const { authenticate } = buildAuthHooks(app);
    const { requireFamilyMember } = buildFamilyAuthHooks(app);
    const storage = opts.storage;
    /* ------------------------------------------------------------------ */
    /* Upload direct (multipart) — le serveur possède les octets            */
    /* ------------------------------------------------------------------ */
    app.post("/families/:familyId/media/upload", {
        preHandler: [
            authenticate,
            requireFamilyMember({ familyParam: "familyId", minPermission: "download" }),
        ],
    }, async (request, reply) => {
        const ctx = request.familyContext;
        const parts = request.parts();
        let file = null;
        let capturedAtRaw = null;
        for await (const part of parts) {
            if (part.type === "file") {
                file = { filename: part.filename, mimetype: part.mimetype, file: part.file };
            }
            else if (part.fieldname === "capturedAt" && typeof part.value === "string") {
                capturedAtRaw = part.value;
            }
        }
        if (!file)
            throw badRequest("Fichier manquant (champ multipart 'file')");
        const mediaId = randomUUID();
        const capturedAt = capturedAtRaw ? new Date(capturedAtRaw) : undefined;
        const result = await storage.upload({
            familyId: ctx.familyId,
            mediaId,
            name: file.filename,
            mime: file.mimetype,
            stream: file.file,
            capturedAt: capturedAt && !Number.isNaN(capturedAt.getTime()) ? capturedAt : undefined,
        });
        const [row] = db
            .insert(media)
            .values({
            id: mediaId,
            familyId: ctx.familyId,
            path: result.path,
            name: file.filename,
            etag: result.etag,
            size: result.size,
            mime: file.mimetype,
            capturedAt: result.capturedAt,
            capturedAtSource: result.capturedAtSource,
            width: result.width,
            height: result.height,
            hasPreview: result.hasPreview,
            uploadedBy: ctx.userId,
        })
            .returning()
            .all();
        reply.status(201).send(serializeMedia(row));
    });
    /* ------------------------------------------------------------------ */
    /* Timeline                                                           */
    /* ------------------------------------------------------------------ */
    /**
     * Timeline paginée — triée par captured_at DESC, cursor = before (ISO).
     */
    app.get("/families/:familyId/media", {
        schema: { querystring: jsonSchema(mediaListQuerySchema) },
        preHandler: [
            authenticate,
            requireFamilyMember({ familyParam: "familyId" }),
        ],
    }, async (request, reply) => {
        const ctx = request.familyContext;
        const before = request.query.before ? new Date(request.query.before) : null;
        const limit = Math.min(request.query.limit ?? 50, 200);
        const rows = db
            .select()
            .from(media)
            .where(and(eq(media.familyId, ctx.familyId), isNull(media.deletedAt), before && !Number.isNaN(before.getTime()) ? lt(media.capturedAt, before) : undefined))
            .orderBy(desc(media.capturedAt))
            .limit(limit + 1)
            .all();
        const hasMore = rows.length > limit;
        const page = rows.slice(0, limit);
        const nextCursor = hasMore && page.length > 0 ? page[page.length - 1].capturedAt.toISOString() : null;
        reply.send({ items: page.map(serializeMedia), nextCursor });
    });
    /**
     * Mois disponibles de la timeline, triés décroissants avec le nombre de
     * médias par mois. UTC imposé pour coller à `monthKeyOf()` côté mobile
     * (getUTCFullYear/getUTCMonth). SQLite : strftime sur les ms (/1000).
     */
    app.get("/families/:familyId/media/months", {
        preHandler: [
            authenticate,
            requireFamilyMember({ familyParam: "familyId" }),
        ],
    }, async (request, reply) => {
        const ctx = request.familyContext;
        const monthCol = sql `strftime('%Y-%m', ${media.capturedAt} / 1000, 'unixepoch')`;
        const rows = db
            .select({
            month: monthCol,
            count: sql `count(*)`,
        })
            .from(media)
            .where(and(eq(media.familyId, ctx.familyId), isNull(media.deletedAt)))
            .groupBy(monthCol)
            .orderBy(desc(monthCol))
            .all();
        reply.send(rows.map((r) => ({ month: r.month, count: r.count })));
    });
    /* ------------------------------------------------------------------ */
    /* Détail, légende                                                    */
    /* ------------------------------------------------------------------ */
    /**
     * Détail d'un média (visionneuse).
     */
    app.get("/media/:mediaId", {
        preHandler: [
            authenticate,
            requireFamilyMember({ mediaParam: "mediaId" }),
        ],
    }, async (request, reply) => {
        const ctx = request.familyContext;
        const params = request.params;
        const [row] = db
            .select()
            .from(media)
            .where(and(eq(media.id, params.mediaId), isNull(media.deletedAt)))
            .limit(1)
            .all();
        if (!row)
            throw notFound();
        reply.send(serializeMedia(row));
    });
    /**
     * Légende d'une photo (coadmin minimum — la lecture reste ouverte à tous
     * les membres). Chaîne vide ou null → légende effacée.
     */
    app.patch("/media/:mediaId/caption", {
        schema: { body: jsonSchema(mediaCaptionUpdateSchema) },
        preHandler: [
            authenticate,
            requireFamilyMember({ mediaParam: "mediaId", minRole: "coadmin" }),
        ],
    }, async (request, reply) => {
        const ctx = request.familyContext;
        const params = request.params;
        const raw = request.body.caption;
        const caption = raw === null ? null : raw.trim() || null;
        const [row] = db
            .update(media)
            .set({ caption, updatedAt: new Date() })
            .where(and(eq(media.id, params.mediaId), isNull(media.deletedAt)))
            .returning({ caption: media.caption })
            .all();
        if (!row)
            throw notFound();
        reply.send({ caption: row.caption });
    });
    /* ------------------------------------------------------------------ */
    /* Miniature + original (le serveur sert les octets)                  */
    /* ------------------------------------------------------------------ */
    /**
     * Miniature générée à la demande (sharp) et cachée sur disque.
     */
    app.get("/media/:mediaId/thumb", {
        schema: {
            querystring: jsonSchema(z.object({ size: z.coerce.number().int().min(16).max(THUMB_MAX).optional() })),
        },
        preHandler: [
            authenticate,
            requireFamilyMember({ mediaParam: "mediaId" }),
        ],
    }, async (request, reply) => {
        const ctx = request.familyContext;
        const params = request.params;
        const size = request.query.size ?? THUMB_DEFAULT;
        const [row] = db
            .select()
            .from(media)
            .where(and(eq(media.id, params.mediaId), isNull(media.deletedAt)))
            .limit(1)
            .all();
        if (!row)
            throw notFound();
        const buf = await storage.getThumb(ctx.familyId, row.id, row.path, size);
        if (!buf) {
            throw new ApiError(404, "no_preview", "Pas de miniature pour ce média");
        }
        reply
            .header("content-type", "image/jpeg")
            .header("cache-control", "public, max-age=86400")
            .send(buf);
    });
    /**
     * Original en streaming, avec support des requêtes Range (lecture vidéo
     * par l'app mobile). Plus de redirection vers une URL pré-signée : le
     * serveur sert les octets lui-même.
     */
    app.get("/media/:mediaId/original", {
        preHandler: [
            authenticate,
            requireFamilyMember({ mediaParam: "mediaId", minPermission: "download" }),
        ],
    }, async (request, reply) => {
        const ctx = request.familyContext;
        const params = request.params;
        const [row] = db
            .select()
            .from(media)
            .where(and(eq(media.id, params.mediaId), isNull(media.deletedAt)))
            .limit(1)
            .all();
        if (!row)
            throw notFound();
        const rangeHeader = request.headers.range;
        const range = rangeHeader ? parseRange(rangeHeader, row.size) : undefined;
        const opened = await storage.openOriginal(ctx.familyId, row.path, range ?? undefined);
        if (range && !opened.stream) {
            // Range invalide (début au-delà de la fin du fichier)
            reply.status(416).header("content-range", `bytes */${opened.size}`).send();
            return;
        }
        reply.header("accept-ranges", "bytes");
        if (range && opened.stream) {
            reply
                .status(206)
                .header("content-range", `bytes ${opened.start}-${opened.end}/${opened.size}`)
                .header("content-length", opened.end - opened.start + 1);
        }
        else {
            reply.header("content-length", opened.size);
        }
        reply.header("content-type", row.mime);
        reply.send(opened.stream);
        // Obligatoire en handler async : sans `return reply`, fastify refait
        // send(undefined) (wrap-thenable) et tronque la réponse à 0 octet —
        // `reply.sent` ne devient vrai qu'à la fin du flux.
        return reply;
    });
    /* ------------------------------------------------------------------ */
    /* Édition + suppression                                              */
    /* ------------------------------------------------------------------ */
    /**
     * Édition photo écrasée en place (crop/rotation — le client a déjà fait
     * son écriture via son propre accès au fichier ou un ré-upload). On
     * rafraîchit size/etag depuis le disque et on purge les miniatures
     * (même id, octets différents).
     */
    app.post("/media/:mediaId/edited", {
        preHandler: [
            authenticate,
            requireFamilyMember({ mediaParam: "mediaId", minRole: "coadmin" }),
        ],
    }, async (request, reply) => {
        const ctx = request.familyContext;
        const params = request.params;
        const [row] = db
            .select()
            .from(media)
            .where(and(eq(media.id, params.mediaId), isNull(media.deletedAt)))
            .limit(1)
            .all();
        const m = row;
        if (!m)
            throw notFound();
        await storage.purgeThumbs(ctx.familyId, m.id);
        const refreshed = await storage.refreshMetadata(ctx.familyId, m.path).catch(() => null);
        if (refreshed) {
            db.update(media)
                .set({ size: refreshed.size, etag: refreshed.etag, updatedAt: new Date() })
                .where(eq(media.id, m.id))
                .run();
        }
        reply.send({ ok: true });
    });
    /**
     * Suppression d'un média — gestionnaire (admin/coadmin) uniquement.
     * 1. Suppression physique du fichier + miniatures.
     * 2. Soft-delete (deleted_at) : la timeline ne montre plus le média.
     */
    app.delete("/media/:mediaId", {
        preHandler: [
            authenticate,
            requireFamilyMember({ mediaParam: "mediaId", minRole: "coadmin" }),
        ],
    }, async (request, reply) => {
        const ctx = request.familyContext;
        const params = request.params;
        const [m] = db
            .select()
            .from(media)
            .where(and(eq(media.id, params.mediaId), isNull(media.deletedAt)))
            .limit(1)
            .all();
        if (!m)
            throw notFound();
        await storage.deleteMedia(ctx.familyId, m.path, m.id);
        db.update(media).set({ deletedAt: new Date() }).where(eq(media.id, params.mediaId)).run();
        reply.send({ ok: true, deleted: true });
    });
}
//# sourceMappingURL=media.js.map