// Serveur local de Poids Plume. Sert l'interface et expose le moteur de traitement.
// Rien ne sort de la machine : tout transite par 127.0.0.1.

import http from 'node:http';
import path from 'node:path';
import { existsSync } from 'node:fs';
import { readFile, writeFile, mkdir, readdir, stat, appendFile, rm } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { spawn, execFileSync } from 'node:child_process';
import { tmpdir, homedir } from 'node:os';
import { randomUUID } from 'node:crypto';

import { EXTENSIONS_LUES } from './lib/traitement.mjs';
import { traiterIsole, demarrerEquipe, arreterEquipe } from './lib/ouvriers.mjs';
import { appliquerModele, formaterIndex } from './lib/nommage.mjs';
import { etatLicence, enregistrerLicence, compterConversion } from './lib/licence.mjs';

const RACINE = path.dirname(fileURLToPath(import.meta.url));
const PUBLIC = path.join(RACINE, 'public');
const PORT = Number(process.env.PORT) || 5178;

// Une fois l'outil installé, son dossier est en lecture seule : les profils et
// les photos produites doivent aller dans les dossiers de l'utilisateur.
// En développement, tout reste dans le dossier du projet.
const DOSSIER_DONNEES = process.env.PP_DONNEES || RACINE;
const FICHIER_PROFILS = path.join(DOSSIER_DONNEES, 'profils.json');
const PROFILS_MODELE = path.join(RACINE, 'profils.json');
const SORTIE_DEFAUT = process.env.PP_SORTIE || path.join(RACINE, 'sortie');

const TYPES = {
  '.html': 'text/html; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.js': 'text/javascript; charset=utf-8',
  '.json': 'application/json; charset=utf-8',
  '.svg': 'image/svg+xml',
};

// --- Réservation des noms de fichiers -----------------------------------
// Le traitement est concurrent : on réserve un nom de façon synchrone le temps
// que le fichier soit écrit. Une fois sur le disque, c'est son existence qui
// fait foi — sans quoi le serveur continuerait d'éviter des noms supprimés.
const nomsReserves = new Set();

function cheminVariante(dossier, base, extension, suffixe) {
  return path.join(dossier, `${base}${suffixe}${extension}`);
}

function reserverBase(dossier, base, extension, suffixes, reutilisables = new Set()) {
  let candidat = base;
  let n = 2;
  // Les fichiers issus d'une conversion précédente de la même photo ne comptent
  // pas comme occupés : on veut réutiliser les mêmes noms, pas en inventer.
  const pris = (c) => nomsReserves.has(path.join(dossier, c))
    || suffixes.some((s) => {
      const chemin = cheminVariante(dossier, c, extension, s);
      return existsSync(chemin) && !reutilisables.has(chemin);
    });
  while (pris(candidat)) {
    candidat = `${base}-${n}`;
    n += 1;
  }
  nomsReserves.add(path.join(dossier, candidat));
  return candidat;
}

// --- Journal --------------------------------------------------------------
const ENTETE_JOURNAL = 'horodatage;nom_origine;nom_final;largeur;hauteur;octets_avant;octets_apres;qualite\n';

async function journaliser(dossier, lignes) {
  const fichier = path.join(dossier, 'journal.csv');
  if (!existsSync(fichier)) await appendFile(fichier, '﻿' + ENTETE_JOURNAL, 'utf8');
  await appendFile(fichier, lignes.join('\n') + '\n', 'utf8');
}

// --- Extrait HTML ---------------------------------------------------------
function extraitHtml(fichiers, alt) {
  if (!fichiers.length) return '';
  const plusGrand = fichiers[0];
  const srcset = fichiers.map((f) => `${f.nom} ${f.largeur}w`).join(',\n              ');
  return `<img src="${plusGrand.nom}"
     srcset="${srcset}"
     sizes="(max-width: 768px) 100vw, 800px"
     width="${plusGrand.largeur}" height="${plusGrand.hauteur}"
     alt="${(alt || '').replace(/"/g, '&quot;')}"
     loading="lazy" decoding="async">`;
}

// --- Corps des requêtes ---------------------------------------------------
function lireCorps(req, limiteOctets = 200 * 1024 * 1024) {
  return new Promise((resolve, reject) => {
    const morceaux = [];
    let total = 0;
    req.on('data', (m) => {
      total += m.length;
      if (total > limiteOctets) {
        reject(new Error('fichier trop volumineux'));
        req.destroy();
        return;
      }
      morceaux.push(m);
    });
    req.on('end', () => resolve(Buffer.concat(morceaux)));
    req.on('error', reject);
  });
}

const repondre = (res, code, donnees) => {
  res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
  res.end(JSON.stringify(donnees));
};

// --- Routes ---------------------------------------------------------------
async function routeEtat(res) {
  let profils = { profils: [] };
  for (const source of [FICHIER_PROFILS, PROFILS_MODELE]) {
    try {
      profils = JSON.parse(await readFile(source, 'utf8'));
      break;
    } catch { /* on tente le modèle livré avec l'application */ }
  }
  repondre(res, 200, {
    ...profils,
    dossierSortieDefaut: SORTIE_DEFAUT,
    edition: await lireEdition(),
    licence: await etatLicence(DOSSIER_DONNEES),
  });
}

/** Éditeur et version, lus dans package.json pour rester toujours exacts. */
let editionEnCache = null;
async function lireEdition() {
  if (editionEnCache) return editionEnCache;
  try {
    const p = JSON.parse(await readFile(path.join(RACINE, 'package.json'), 'utf8'));
    const [majeur, mineur] = String(p.version).split('.');
    editionEnCache = {
      editeur: p.author?.name ?? '',
      version: `${majeur}.${mineur}`,
      annee: new Date().getFullYear(),
      boutique: p.poidsPlume?.boutique ?? '',
      support: p.poidsPlume?.support ?? '',
    };
  } catch {
    editionEnCache = { editeur: '', version: '', annee: '' };
  }
  return editionEnCache;
}

async function routeScan(res, corps) {
  const { dossier } = JSON.parse(corps);
  if (!dossier || !existsSync(dossier)) {
    return repondre(res, 400, { erreur: "Ce dossier n'existe pas." });
  }
  const entrees = await readdir(dossier, { withFileTypes: true });
  const fichiers = [];
  for (const e of entrees) {
    if (!e.isFile()) continue;
    if (!EXTENSIONS_LUES.has(path.extname(e.name).toLowerCase())) continue;
    const complet = path.join(dossier, e.name);
    const infos = await stat(complet);
    fichiers.push({ chemin: complet, nom: e.name, taille: infos.size });
  }
  fichiers.sort((a, b) => a.nom.localeCompare(b.nom, 'fr', { numeric: true }));
  repondre(res, 200, { fichiers });
}

async function routeTraiter(res, req, corps) {
  let options;
  let octets = null;
  let chemin = null;
  let nomOrigine;
  let index;
  let anciens = [];

  if (req.headers['x-parametres']) {
    const p = JSON.parse(Buffer.from(req.headers['x-parametres'], 'base64').toString('utf8'));
    options = p.options;
    nomOrigine = p.nom;
    index = p.index;
    anciens = p.anciens ?? [];
    octets = corps;
  } else {
    const p = JSON.parse(corps);
    options = p.options;
    nomOrigine = p.nom ?? path.basename(p.chemin);
    index = p.index;
    anciens = p.anciens ?? [];
    chemin = p.chemin;
  }

  // Periode d'evaluation : au-dela du quota, la conversion est refusee.
  const licence = await etatLicence(DOSSIER_DONNEES);
  if (!licence.sousLicence && licence.epuisee) {
    return repondre(res, 402, {
      erreur: "Période d'évaluation terminée. Activez une licence pour continuer.",
      licenceRequise: true,
    });
  }

  const dossierSortie = options.dossierSortie || SORTIE_DEFAUT;
  await mkdir(dossierSortie, { recursive: true });

  // Les octets reçus par téléversement sont déposés dans un fichier temporaire :
  // l'ouvrier travaille toujours à partir d'un chemin, jamais d'un tampon transmis.
  let temporaire = null;
  if (octets) {
    temporaire = path.join(tmpdir(), `poids-plume-${randomUUID()}${path.extname(nomOrigine)}`);
    await writeFile(temporaire, octets);
  }

  const base = appliquerModele(options.modele, {
    'mot-cle': options.motCle,
    detail: options.detail,
    ville: options.ville,
    departement: options.departement,
    marque: options.marque,
    original: path.parse(nomOrigine).name,
    index: formaterIndex(index ?? 1),
  });

  const format = options.format === 'avif' ? '.avif' : '.webp';
  const largeursDemandees = options.largeurs?.length ? options.largeurs : [];
  const suffixesPossibles = largeursDemandees.length > 1
    ? ['', ...largeursDemandees.map((l) => `-${l}w`)]
    : [''];
  // Par sécurité, on ne réutilise que des chemins situés dans le dossier de sortie.
  const racine = path.resolve(dossierSortie);
  const reutilisables = new Set(
    (Array.isArray(anciens) ? anciens : [])
      .map((c) => path.resolve(String(c)))
      .filter((c) => c.startsWith(racine + path.sep)),
  );

  const baseUnique = reserverBase(dossierSortie, base, format, suffixesPossibles, reutilisables);

  let resultat;
  try {
    resultat = await traiterIsole({
      chemin: temporaire ?? chemin,
      options,
      base: baseUnique,
      dossierSortie,
    });
  } finally {
    nomsReserves.delete(path.join(dossierSortie, baseUnique));
    if (temporaire) await rm(temporaire, { force: true });
  }

  const { fichiers } = resultat;

  // Les tailles produites ont pu changer : on efface les fichiers de la
  // conversion précédente qui n'ont pas été réécrits.
  const nouveaux = new Set(fichiers.map((f) => path.resolve(f.chemin)));
  for (const ancien of reutilisables) {
    if (!nouveaux.has(ancien)) await rm(ancien, { force: true });
  }

  const totalKo = Math.round(fichiers.reduce((s, f) => s + f.taille, 0) / 1024);
  console.log(`  ${nomOrigine} -> ${fichiers.length} fichier(s), ${totalKo} Ko`);

  if (!licence.sousLicence) await compterConversion(DOSSIER_DONNEES);

  const horodatage = new Date().toISOString().slice(0, 19).replace('T', ' ');
  await journaliser(dossierSortie, fichiers.map((f) => [
    horodatage, nomOrigine, f.nom, f.largeur, f.hauteur,
    resultat.avant.taille, f.taille, f.qualite,
  ].join(';')));

  repondre(res, 200, {
    ok: true,
    nomOrigine,
    licence: await etatLicence(DOSSIER_DONNEES),
    avant: resultat.avant,
    transparente: resultat.transparente,
    alphaConservee: resultat.alphaConservee,
    decodeurSecours: resultat.decodeurSecours,
    apercuAvant: resultat.apercuAvant,
    apercuApres: resultat.apercuApres,
    fichiers,
    html: extraitHtml(fichiers, options.alt),
  });
}

/**
 * Ouvre le sélecteur de dossier de Windows et renvoie le chemin choisi.
 * Chaîne vide si l'utilisateur annule.
 */
/**
 * Les dossiers usuels de l'utilisateur, pour ne pas avoir à remonter la
 * racine du disque à chaque fois. Les noms de dossiers restent en anglais
 * sur disque même en Windows français, et OneDrive peut les avoir déplacés :
 * on retient la première variante qui existe réellement.
 */
function raccourcisUtilisateur() {
  const maison = homedir();
  const candidats = [
    ['Bureau', ['OneDrive/Bureau', 'OneDrive/Desktop', 'Desktop', 'Bureau']],
    ['Téléchargements', ['Downloads', 'Téléchargements']],
    ['Documents', ['OneDrive/Documents', 'Documents']],
    ['Images', ['OneDrive/Images', 'OneDrive/Pictures', 'Pictures', 'Images']],
    ['Dossier personnel', ['.']],
  ];

  const liste = [];
  for (const [nom, relatifs] of candidats) {
    for (const relatif of relatifs) {
      const complet = path.resolve(maison, relatif);
      if (existsSync(complet)) {
        liste.push({ nom, chemin: complet });
        break;
      }
    }
  }
  if (existsSync(SORTIE_DEFAUT)) liste.push({ nom: 'Sorties Poids Plume', chemin: SORTIE_DEFAUT });
  return liste;
}

/**
 * Contenu d'un dossier, limité aux sous-dossiers : de quoi naviguer depuis
 * l'interface, sans dépendre d'une boîte de dialogue du système.
 */
async function listerDossiers(demande) {
  const lecteurs = [];
  for (const lettre of 'CDEFGHIJKLMNOPQRSTUVWXYZ') {
    if (existsSync(`${lettre}:\\`)) lecteurs.push(`${lettre}:\\`);
  }

  let courant = demande && existsSync(demande) ? path.resolve(demande) : SORTIE_DEFAUT;
  if (!existsSync(courant)) courant = lecteurs[0] ?? 'C:\\';

  let dossiers = [];
  let avertissement = null;
  try {
    const entrees = await readdir(courant, { withFileTypes: true });
    dossiers = entrees
      .filter((e) => e.isDirectory() && !e.name.startsWith('$'))
      .filter((e) => e.name !== 'System Volume Information')
      .map((e) => e.name)
      .sort((a, b) => a.localeCompare(b, 'fr', { numeric: true }));
  } catch (e) {
    avertissement = `Dossier illisible : ${e.code === 'EPERM' || e.code === 'EACCES' ? 'accès refusé' : e.message}`;
  }

  const parent = path.dirname(courant);
  return {
    courant,
    parent: parent === courant ? null : parent,
    dossiers,
    lecteurs,
    raccourcis: raccourcisUtilisateur(),
    avertissement,
  };
}

/**
 * Ouvre un dossier dans l'explorateur, au premier plan.
 *
 * Une fois l'outil empaqueté, ses fichiers vivent dans une archive « app.asar ».
 * Node sait la lire, mais wscript.exe est un programme extérieur qui ne le peut
 * pas : le script doit donc être extrait de l'archive, d'où le chemin corrigé
 * ci-dessous. Si pour une raison quelconque il manque, on ouvre quand même le
 * dossier — simplement sans le mettre au premier plan.
 */
function ouvrirDansExplorateur(dossier) {
  const script = path.join(RACINE, 'lib', 'ouvrir-dossier.vbs')
    .replace(`${path.sep}app.asar${path.sep}`, `${path.sep}app.asar.unpacked${path.sep}`);

  if (existsSync(script)) {
    spawn('wscript.exe', [script, dossier], { detached: true, stdio: 'ignore' }).unref();
    return;
  }

  console.error(`  [ouverture] script introuvable : ${script}`);
  spawn('cmd', ['/c', 'start', '', dossier], { detached: true, stdio: 'ignore' }).unref();
}

async function servirStatique(res, url) {
  const relatif = url === '/' ? 'index.html' : decodeURIComponent(url.slice(1));
  const fichier = path.join(PUBLIC, relatif);
  if (!fichier.startsWith(PUBLIC) || !existsSync(fichier)) {
    res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
    res.end('Introuvable');
    return;
  }
  const contenu = await readFile(fichier);
  res.writeHead(200, {
    'Content-Type': TYPES[path.extname(fichier).toLowerCase()] ?? 'application/octet-stream',
    'Cache-Control': 'no-store',
  });
  res.end(contenu);
}

// --- Serveur --------------------------------------------------------------
const serveur = http.createServer(async (req, res) => {
  try {
    const url = new URL(req.url, 'http://localhost').pathname;

    if (req.method === 'GET' && url === '/api/etat') return await routeEtat(res);

    if (req.method === 'PUT' && url === '/api/profils') {
      const corps = await lireCorps(req, 2 * 1024 * 1024);
      const donnees = JSON.parse(corps.toString('utf8'));
      await writeFile(FICHIER_PROFILS, JSON.stringify(donnees, null, 2), 'utf8');
      return repondre(res, 200, { ok: true });
    }

    if (req.method === 'POST' && url === '/api/scan') {
      return await routeScan(res, (await lireCorps(req, 1024 * 1024)).toString('utf8'));
    }

    if (req.method === 'POST' && url === '/api/traiter') {
      const corps = await lireCorps(req);
      return await routeTraiter(res, req, req.headers['x-parametres'] ? corps : corps.toString('utf8'));
    }

    if (req.method === 'POST' && url === '/api/licence') {
      const { jeton } = JSON.parse((await lireCorps(req, 16384)).toString('utf8'));
      const controle = await enregistrerLicence(DOSSIER_DONNEES, jeton);
      if (!controle.valide) return repondre(res, 400, { erreur: controle.raison });
      console.log(`  licence activée : ${controle.donnees.client}`);
      return repondre(res, 200, { ok: true, licence: await etatLicence(DOSSIER_DONNEES) });
    }

    if (req.method === 'POST' && url === '/api/lister-dossiers') {
      const { chemin } = JSON.parse((await lireCorps(req, 8192)).toString('utf8'));
      return repondre(res, 200, await listerDossiers(chemin));
    }

    if (req.method === 'POST' && url === '/api/creer-dossier') {
      const { parent, nom } = JSON.parse((await lireCorps(req, 8192)).toString('utf8'));
      const propre = String(nom || '').replace(/[\\/:*?"<>|]/g, '').trim();
      if (!propre) return repondre(res, 400, { erreur: 'Nom de dossier invalide.' });
      const complet = path.join(parent, propre);
      await mkdir(complet, { recursive: true });
      return repondre(res, 200, { dossier: complet });
    }

    if (req.method === 'POST' && url === '/api/quitter') {
      repondre(res, 200, { ok: true });
      console.log('  arrêt demandé depuis l\'interface');
      setTimeout(() => { arreterEquipe(); process.exit(0); }, 250);
      return undefined;
    }

    if (req.method === 'POST' && url === '/api/ouvrir-dossier') {
      const { dossier } = JSON.parse((await lireCorps(req, 8192)).toString('utf8'));
      if (!dossier || !existsSync(dossier)) {
        return repondre(res, 404, { erreur: "Ce dossier n'existe pas encore." });
      }
      ouvrirDansExplorateur(dossier);
      return repondre(res, 200, { ok: true, dossier });
    }

    if (req.method === 'GET') return await servirStatique(res, url);

    repondre(res, 405, { erreur: 'Méthode non autorisée' });
  } catch (e) {
    // Toute erreur est renvoyée au navigateur ET tracée dans cette fenêtre,
    // pour qu'un échec ne se résume jamais à un message vide côté interface.
    console.error(`  [erreur] ${req.method} ${req.url} : ${e.message}`);
    if (!res.headersSent) repondre(res, 500, { erreur: e.message });
    else res.end();
  }
});

// Le serveur ne doit pas mourir sur une erreur isolée : sinon l'interface
// reste affichée alors que plus rien ne répond derrière.
for (const signal of ['SIGINT', 'SIGTERM']) {
  process.on(signal, () => { arreterEquipe(); process.exit(0); });
}

process.on('uncaughtException', (e) => {
  console.error(`\n  [anomalie] ${e.message}\n  Le serveur continue de tourner.\n`);
});
process.on('unhandledRejection', (e) => {
  console.error(`\n  [anomalie] ${e?.message ?? e}\n  Le serveur continue de tourner.\n`);
});

const ADRESSE = `http://localhost:${PORT}`;

function ouvrirNavigateur() {
  if (process.env.SANS_NAVIGATEUR === '1') return;
  spawn('cmd', ['/c', 'start', '', ADRESSE], { detached: true, stdio: 'ignore' }).unref();
}

serveur.on('listening', () => {
  const effectif = demarrerEquipe();
  console.log(`\n  Poids Plume est prêt : ${ADRESSE}`);
  console.log(`  Dossier de sortie par défaut : ${SORTIE_DEFAUT}`);
  console.log(`  ${effectif} processus de traitement, isolés du serveur`);
  console.log('  Pour arrêter : bouton « Quitter » dans l\'interface, ou Ctrl+C\n');
  ouvrirNavigateur();
});

/** Une instance saine de Poids Plume répond-elle déjà sur ce port ? */
async function instanceEnBonneSante() {
  for (let essai = 0; essai < 2; essai += 1) {
    try {
      const r = await fetch(`${ADRESSE}/api/etat`, { signal: AbortSignal.timeout(3000) });
      if (r.ok && typeof (await r.json()).dossierSortieDefaut === 'string') return true;
    } catch { /* on retente une fois : le serveur peut être occupé */ }
  }
  return false;
}

/** Ferme les restes d'une instance figée. On ne touche qu'à nos propres processus. */
function fermerInstancesFigees() {
  // process.pid est celui de CE serveur : sans cette exclusion explicite,
  // la commande se supprimerait elle-même. ($PID désignerait PowerShell.)
  const script = `$moi = ${process.pid};`
    + " $c = @(Get-CimInstance Win32_Process -Filter \"Name='node.exe'\""
    + " | Where-Object { $_.CommandLine -match 'serveur\\.mjs|ouvrier\\.mjs' -and $_.ProcessId -ne $moi });"
    + ' $c | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue };'
    + ' Write-Output $c.Count';
  try {
    const sortie = execFileSync('powershell', ['-NoProfile', '-Command', script], {
      encoding: 'utf8', timeout: 15000, windowsHide: true,
    });
    return Number(String(sortie).trim()) || 0;
  } catch {
    return 0;
  }
}

let nettoyageTente = false;

serveur.on('error', async (e) => {
  if (e.code !== 'EADDRINUSE') throw e;

  // Cas normal : l'outil tourne déjà. On ouvre simplement la page et on se retire,
  // sans rien demander à l'utilisateur. Code 3 : la fenêtre se ferme toute seule.
  if (await instanceEnBonneSante()) {
    // Dans l'application de bureau, la fenêtre affichera simplement l'instance
    // déjà en place : il ne faut surtout pas fermer le programme.
    if (process.env.PP_ELECTRON === '1') {
      console.log('  un moteur répond déjà sur ce port : la fenêtre s\'y connecte');
      return;
    }
    console.log(`\n  Poids Plume tourne déjà. La page s'ouvre : ${ADRESSE}\n`);
    ouvrirNavigateur();
    process.exit(3);
  }

  // Le port est pris mais rien ne répond : c'est une instance figée. On la
  // ferme nous-mêmes plutôt que d'envoyer l'utilisateur cliquer ailleurs.
  if (!nettoyageTente) {
    nettoyageTente = true;
    const fermes = fermerInstancesFigees();
    if (fermes > 0) {
      console.log(`\n  Une session précédente était figée : ${fermes} processus fermé(s).`);
      console.log('  Redémarrage...\n');
      setTimeout(() => serveur.listen(PORT, '127.0.0.1'), 1200);
      return;
    }
  }

  console.error(`\n  Le port ${PORT} est occupé par un autre programme que Poids Plume.`);
  console.error('  Fermez ce programme, ou changez de port :  set PORT=5179 && node serveur.mjs\n');
  process.exit(1);
});

serveur.listen(PORT, '127.0.0.1');
