// Chaîne de traitement d'une image : décodage, couleurs, redimensionnement,
// encodage sous contrainte de poids. Fonctions F1, F2, F3 et F5 du cahier des charges.

import sharp from 'sharp';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import path from 'node:path';

// Sur Windows, le cache de sharp garde les fichiers verrouillés : on le désactive.
sharp.cache(false);

// --- Profils couleur (F1) ------------------------------------------------
// sharp ne convertit pas les pixels d'un espace à l'autre : il sait seulement
// embarquer un profil. On conserve donc le profil d'origine dans le fichier
// de sortie, et c'est le navigateur qui fait la conversion — solution sûre,
// qui coûte environ 0,5 Ko et évite toute dérive de couleur.
const profilsEnCache = new Map();

async function fichierProfil(icc) {
  const cle = createHash('sha1').update(icc).digest('hex').slice(0, 16);
  const connu = profilsEnCache.get(cle);
  if (connu) return connu;
  const dossier = path.join(tmpdir(), 'poids-plume-profils');
  await mkdir(dossier, { recursive: true });
  const chemin = path.join(dossier, `${cle}.icc`);
  await writeFile(chemin, icc);
  profilsEnCache.set(cle, chemin);
  return chemin;
}

export const EXTENSIONS_LUES = new Set([
  '.jpg', '.jpeg', '.png', '.webp', '.avif', '.tif', '.tiff',
  '.bmp', '.gif', '.heic', '.heif',
]);

/**
 * Ouvre une image, avec repli sur le décodeur WebAssembly quand libheif
 * refuse le fichier (cas des HEIC d'iPhone et d'Android).
 * Renvoie une fabrique : chaque appel donne une chaîne sharp neuve.
 */
async function ouvrir(octets) {
  try {
    const meta = await sharp(octets, { failOn: 'none' }).metadata();
    if (!meta.width || !meta.height) throw new Error('dimensions absentes');
    return {
      meta,
      secours: false,
      creer: () => sharp(octets, { failOn: 'none' }).rotate(),
    };
  } catch (erreurSharp) {
    let decode;
    try {
      decode = (await import('heic-decode')).default;
    } catch {
      throw erreurSharp;
    }
    let image;
    try {
      image = await decode({ buffer: octets });
    } catch {
      throw erreurSharp;
    }
    const brut = Buffer.from(image.data);
    const info = { width: image.width, height: image.height, channels: 4 };
    return {
      meta: { format: 'heic', width: image.width, height: image.height, hasAlpha: true },
      secours: true,
      creer: () => sharp(brut, { raw: info }),
    };
  }
}

/** Une image n'est « transparente » que si son canal alpha est réellement utilisé. */
async function estTransparente(source) {
  if (!source.meta.hasAlpha) return false;
  try {
    const stats = await source.creer().stats();
    return !stats.isOpaque;
  } catch {
    return Boolean(source.meta.hasAlpha);
  }
}

const MIN_QUALITE = 30;
const MAX_QUALITE = 95;

/**
 * Cherche la qualité la plus haute qui tienne dans le budget de poids (F5).
 * Recherche par dichotomie : sept encodages au maximum, sans redécoder l'image.
 */
async function encoderSousBudget(encoder, budgetOctets) {
  let bas = MIN_QUALITE;
  let haut = MAX_QUALITE;
  let retenu = null;
  let plusPetit = null;

  while (bas <= haut) {
    const q = Math.round((bas + haut) / 2);
    const octets = await encoder(q);
    if (!plusPetit || octets.length < plusPetit.octets.length) plusPetit = { q, octets };
    if (octets.length <= budgetOctets) {
      retenu = { q, octets };
      bas = q + 1;
    } else {
      haut = q - 1;
    }
  }

  if (retenu) return { ...retenu, budgetTenu: true };
  return { ...plusPetit, budgetTenu: false };
}

function taillesUtiles(options, source) {
  // Selon la contrainte choisie, la valeur porte sur la largeur ou sur le côté long.
  const reference = options.contrainte === 'cote-long'
    ? Math.max(source.width, source.height)
    : source.width;

  const demandees = (options.largeurs?.length ? options.largeurs : [reference])
    .map(Number)
    .filter((n) => Number.isFinite(n) && n > 0);

  const gardees = options.pasAgrandir === false
    ? demandees
    : demandees.map((l) => Math.min(l, reference));

  return [...new Set(gardees)].sort((a, b) => b - a);
}

async function versDataURI(pipeline, largeur = 420) {
  const octets = await pipeline
    .resize({ width: largeur, withoutEnlargement: true })
    .webp({ quality: 72 })
    .toBuffer();
  return `data:image/webp;base64,${octets.toString('base64')}`;
}

/**
 * Traite une image et renvoie les variantes encodées en mémoire.
 * L'écriture disque est faite par l'appelant, qui maîtrise les noms de fichiers.
 */
export async function traiter(entree, options = {}) {
  const octetsSource = entree.octets ?? (await readFile(entree.chemin));
  const source = await ouvrir(octetsSource);
  const transparente = await estTransparente(source);

  const conserverAlpha = transparente && options.transparence !== 'aplatir';
  const tailles = taillesUtiles(options, source.meta);
  const format = options.format === 'avif' ? 'avif' : 'webp';
  const budgetOctets = options.budgetKo ? Math.round(Number(options.budgetKo) * 1024) : null;
  const chroma = options.chroma444 ? '4:4:4' : '4:2:0';

  // Profil couleur à réattacher en sortie, le cas échéant (F1).
  const profil = options.profilCouleur !== 'ignorer' && source.meta.icc
    ? await fichierProfil(source.meta.icc)
    : null;

  const sorties = [];

  for (const taille of tailles) {
    const cadrage = options.contrainte === 'cote-long'
      ? { width: taille, height: taille, fit: 'inside' }
      : { width: taille };

    let chaine = source.creer().resize({
      ...cadrage,
      withoutEnlargement: options.pasAgrandir !== false,
      kernel: 'lanczos3',
    });

    if (!conserverAlpha) {
      chaine = chaine.flatten({ background: options.couleurFond || '#ffffff' });
    }
    if (options.nettete !== false) {
      chaine = chaine.sharpen({ sigma: 0.7, m1: 0.5, m2: 2 });
    }

    // Pixels finaux calculés une seule fois, puis encodés autant de fois
    // que la recherche de qualité l'exige.
    const { data, info } = await chaine.raw().toBuffer({ resolveWithObject: true });
    const encoder = (qualite) => {
      let p = sharp(data, { raw: info });
      if (profil) p = p.withIccProfile(profil);
      return format === 'avif'
        ? p.avif({ quality: qualite, chromaSubsampling: chroma }).toBuffer()
        : p.webp({ quality: qualite, alphaQuality: 100, chromaSubsampling: chroma }).toBuffer();
    };

    let octets;
    let qualite;
    let budgetTenu = true;

    if (budgetOctets) {
      const resultat = await encoderSousBudget(encoder, budgetOctets);
      octets = resultat.octets;
      qualite = resultat.q;
      budgetTenu = resultat.budgetTenu;
    } else {
      qualite = Number(options.qualite) || 78;
      octets = await encoder(qualite);
    }

    sorties.push({
      cible: taille,
      largeur: info.width,
      hauteur: info.height,
      octets,
      taille: octets.length,
      qualite,
      budgetTenu,
    });
  }

  return {
    avant: {
      format: source.meta.format,
      largeur: source.meta.width,
      hauteur: source.meta.height,
      taille: octetsSource.length,
      profilIcc: Boolean(source.meta.icc),
    },
    transparente,
    alphaConservee: conserverAlpha,
    decodeurSecours: source.secours,
    extension: format === 'avif' ? '.avif' : '.webp',
    sorties,
    apercuAvant: await versDataURI(source.creer()),
    apercuApres: sorties.length ? await versDataURI(sharp(sorties[0].octets)) : null,
  };
}

/** Lecture rapide des caractéristiques, sans traitement. */
export async function analyser(chemin) {
  const octets = await readFile(chemin);
  const source = await ouvrir(octets);
  return {
    largeur: source.meta.width,
    hauteur: source.meta.height,
    format: source.meta.format,
    taille: octets.length,
  };
}
