/*
  Verse dans la GED Dolibarr les pièces déclarées par gedManifest.ts.

  POURQUOI CE N'EST PAS UN SIMPLE UPLOAD : la GED de Dolibarr est pilotée par
  les objets. `/ecm/index_auto.php` expose les documents rattachés à une facture,
  un tiers, une charge ; `/ecm/index.php` n'expose que les répertoires MANUELS,
  enregistrés en base. Déposer un fichier via l'API dans un chemin libre le pose
  bien sur le disque, mais aucun écran ne le montrera jamais — l'API accepte
  n'importe quel `subdir` sans broncher. Vérifié le 2026-08-13 : un dépôt dans
  `ecm/social/urssaf/2026` laissait l'arbre GED vide.

  Il faut donc CRÉER le répertoire par l'UI (seule voie qui l'inscrive en base),
  puis y téléverser par l'API. C'est ce que fait ce script.

  Les pièces qui justifient un fait comptable n'ont rien à faire ici : elles se
  rattachent à leur objet. Le PDF de l'appel URSSAF est attaché aux charges, une
  facture fournisseur à sa facture. La GED manuelle est pour ce qui n'a pas
  d'objet : statuts, KBIS, registre des décisions, convention de domiciliation.

  Idempotent : un répertoire déjà présent n'est pas recréé, un fichier déjà
  présent est réécrit à l'identique (`overwriteifexists`).

  Tout passe par l'UI — répertoires ET fichiers. L'API REST n'est pas une
  alternative ici : voir le commentaire de `upload()`, elle répond succès sans
  rien déposer que la GED sache retrouver.

  Usage :
    deno run -A test/provisionGed.ts --docs ~/Work/Arcodange/1_DOCUMENTS [--dry-run]
*/
import "load_dotenv";
import { chromium, type Page } from "playwright";
import login from "./scripts/login.ts";
import { assertSandbox } from "./scripts/guard.ts";
import { DIRS, FILES } from "./gedManifest.ts";

const argv = Deno.args;
const pick = (f: string, d = "") => (argv.includes(f) ? argv[argv.indexOf(f) + 1] : d);

const docsRoot = pick("--docs", `${Deno.env.get("HOME")}/Work/Arcodange/1_DOCUMENTS`)
  .replace(/^~/, Deno.env.get("HOME") ?? "~");
const dryRun = argv.includes("--dry-run");

const dolibarrAddress = assertSandbox();

console.log(`cible   : ${dolibarrAddress}`);
console.log(`source  : ${docsRoot}`);
console.log(`manifeste : ${DIRS.length} répertoires, ${FILES.length} pièces\n`);

// Une pièce absente du disque est une erreur de manifeste, pas un incident à
// ignorer : on refuse de partir plutôt que de livrer un versement partiel dont
// personne ne saura ce qui manque.
const missing = FILES.filter((f) => {
  try {
    return !Deno.statSync(`${docsRoot}/${f.src}`).isFile;
  } catch {
    return true;
  }
});
if (missing.length) {
  console.error("INTROUVABLES — le manifeste ne correspond plus au disque :");
  for (const m of missing) console.error(`   ${m.src}`);
  Deno.exit(1);
}

if (dryRun) {
  for (const d of DIRS) console.log(`  répertoire  ${d.parent ? d.parent + "/" : ""}${d.label}`);
  for (const f of FILES) console.log(`  pièce       ${f.dir}/${f.as}   ← ${f.src}`);
  console.log("\n--dry-run : rien n'est écrit.");
  Deno.exit(0);
}

/**
 * L'arbre GED, lu tel qu'il est : chemin de chaque répertoire → nombre de
 * documents qu'il porte.
 *
 * DEUX PIÈGES, tous deux rencontrés :
 *  - l'arbre est un arbre jQuery REPLIÉ. `innerText` sur son conteneur renvoie
 *    une chaîne vide alors que les répertoires existent — conclure de là qu'ils
 *    manquent fait relancer la création, qui répond « existe déjà », et le
 *    script annonce l'échec d'une opération réussie ;
 *  - ses ancres portent `href="#"`, pas `section=`. Le chemin vit dans
 *    l'attribut `rel` (« Juridique/Identite/ »). Chercher un lien `section=`
 *    ne ramène rien, quel que soit le contenu réel de l'arbre.
 * Les nœuds repliés restent dans le DOM : `evaluateAll` les atteint.
 */
async function readTree(page: Page): Promise<Map<string, { id: number; docs: number }>> {
  await page.goto(`${dolibarrAddress}/ecm/index.php`);
  // L'arbre est monté par jQuery APRÈS le chargement : lire tout de suite ne
  // ramène rien et fait conclure à un arbre vide.
  await page.waitForSelector("li.lidirecm", { timeout: 15000 }).catch(() => {});

  // Et il ne monte QUE la racine : les sous-répertoires arrivent par ajax au
  // dépliage. Sans cette boucle, tout ce qui n'est pas à la racine est déclaré
  // absent alors qu'il existe. On déplie jusqu'à stabilisation, en bornant les
  // passes — un arbre qui ne se stabilise pas est un bug, pas une raison de
  // tourner indéfiniment.
  for (let pass = 0; pass < 6; pass++) {
    const collapsed = page.locator("li.directory.collapsed > a.fmdirlia");
    const n = await collapsed.count();
    if (n === 0) break;
    for (let i = 0; i < n; i++) {
      await collapsed.nth(i).click({ timeout: 5000 }).catch(() => {});
    }
    await page.waitForTimeout(600);
  }

  const rows = await page.locator("li.lidirecm").evaluateAll(
    // Deno n'embarque pas la lib DOM : le callback s'exécute dans le navigateur.
    (lis: {
      querySelector(s: string): { getAttribute(n: string): string | null } | null;
      querySelectorAll(s: string): ArrayLike<{ textContent: string | null }>;
    }[]) =>
      lis.map((li) => {
        const a = li.querySelector("a.fmdirlia");
        const cells = Array.from(li.querySelectorAll("td.right"));
        // L'identifiant de section ne vit que dans le handler : loadandshowpreview('chemin',ID)
        const id = Number((a?.getAttribute("onclick") ?? "").match(/,\s*(\d+)\s*\)/)?.[1] ?? "0");
        return {
          path: (a?.getAttribute("rel") ?? "").replace(/\/$/, ""),
          id,
          docs: Number((cells[0]?.textContent ?? "").trim() || "0"),
        };
      }),
  );
  return new Map(rows.filter((r) => r.path).map((r) => [r.path, { id: r.id, docs: r.docs }]));
}

/** Chemin d'un répertoire du manifeste, tel que l'arbre et l'API le nomment. */
const pathOf = (label: string): string => {
  const d = DIRS.find((x) => x.label === label);
  return d?.parent ? `${pathOf(d.parent)}/${label}` : label;
};

/**
 * Crée un répertoire. Le formulaire est la seule autorité sur l'existant :
 * il répond « existe déjà », ce qui vaut succès pour un script idempotent.
 */
async function createDir(page: Page, label: string, parent: string | null, desc: string): Promise<"créé" | "déjà présent"> {
  await page.goto(`${dolibarrAddress}/ecm/dir_add_card.php?action=create&module=ecm`);
  await page.fill('input[name="label"]', label);
  await page.fill('textarea[name="desc"]', desc).catch(() => {});
  if (parent) {
    // Le select des parents porte les répertoires déjà créés ; on l'apparie sur
    // le libellé visible, jamais sur un id qui diffère d'une instance à l'autre.
    const opts = await page.locator('select[name="catParent"] option').evaluateAll(
      (os: { textContent: string | null; getAttribute(n: string): string | null }[]) =>
        os.map((o) => ({
          text: (o.textContent ?? "").trim(),
          value: o.getAttribute("value") ?? "",
        })),
    );
    const hit = opts.find((o) => o.text === parent) ?? opts.find((o) => o.text.includes(parent));
    if (!hit) {
      throw new Error(`parent « ${parent} » absent du select (options : ${opts.map((o) => o.text).join(", ")})`);
    }
    await page.selectOption('select[name="catParent"]', hit.value);
  }
  await page.locator('input[name="create"], input[type="submit"]').first().click();
  await page.waitForLoadState("networkidle");
  const body = (await page.locator("body").innerText()).replace(/\s+/g, " ");
  if (/existe d[ée]j[àa]/i.test(body)) return "déjà présent";
  const err = body.match(/Erreur[^.]{0,160}/i);
  if (err) throw new Error(`${label} : ${err[0]}`);
  return "créé";
}

/**
 * Téléverse une pièce dans un répertoire de la GED, par le formulaire de l'UI.
 *
 * POURQUOI PAS L'API. `POST /documents/upload` avec `modulepart=ecm` répond le
 * nom du fichier — donc succès — et ne dépose RIEN que la GED sache retrouver :
 * l'arbre affiche « Pas de documents téléversés » et `/documents/download`
 * répond 404 sur le chemin qu'on vient d'écrire. Vérifié le 2026-08-13 sur les
 * onze pièces. Le formulaire de l'UI, lui, écrit au bon endroit ET inscrit le
 * fichier à l'index — c'est cet index qui alimente les compteurs et la
 * recherche. Une pièce non indexée est une pièce perdue.
 *
 * Le renommage passe par une copie temporaire : le formulaire envoie le nom du
 * fichier sur disque, il n'offre aucun champ pour le choisir.
 */
/** Noms des fichiers réellement présents dans un répertoire de la GED. */
async function filesIn(page: Page, sectionId: number): Promise<Set<string>> {
  await page.goto(`${dolibarrAddress}/ecm/index.php?section=${sectionId}&file_manager=1`);
  await page.waitForTimeout(800);
  const hrefs = await page.locator('a[href*="document.php"]').evaluateAll(
    (as: { getAttribute(n: string): string | null }[]) =>
      as.map((a) => (a.getAttribute("href") ?? "").match(/file=([^&]+)/)?.[1] ?? "").filter(Boolean),
  );
  return new Set(hrefs.map((h) => decodeURIComponent(h).split("/").pop() ?? ""));
}

async function upload(page: Page, f: typeof FILES[number], sectionId: number, tmp: string): Promise<"versé" | "déjà présent"> {
  // Idempotence par le NOM, pas par la case « écraser » du formulaire : celle-ci
  // ne s'applique pas de façon fiable et chaque passe ajoutait un doublon — deux
  // exemplaires du même statut dans la GED, personne ne sachant lequel fait foi.
  if ((await filesIn(page, sectionId)).has(f.as)) return "déjà présent";

  const staged = `${tmp}/${f.as}`;
  await Deno.copyFile(`${docsRoot}/${f.src}`, staged);

  await page.goto(`${dolibarrAddress}/ecm/index.php?section=${sectionId}&file_manager=1`);
  await page.waitForSelector('input[name="userfile[]"]', { timeout: 15000 });

  // Au-delà de MAX_FILE_SIZE, le formulaire accepte la soumission, n'écrit rien
  // et n'affiche aucune erreur : le fichier disparaît sans bruit. C'est ainsi
  // qu'un bail de 8,6 Mo a été porté « versé » alors qu'il ne l'était pas.
  const limit = Number(
    await page.locator('input[name="MAX_FILE_SIZE"]').first().getAttribute("value") ?? "0",
  );
  const size = (await Deno.stat(staged)).size;
  if (limit && size > limit) {
    throw new Error(
      `${f.as} : ${(size / 1048576).toFixed(1)} Mo dépasse la limite de ` +
        `${(limit / 1048576).toFixed(1)} Mo. Relever MAIN_UPLOAD_DOC (Dolibarr) ` +
        `et upload_max_filesize (PHP), ou retirer la pièce du manifeste.`,
    );
  }

  await page.setInputFiles('input[name="userfile[]"]', staged);
  await page.locator('input[name="overwritefile"]').check().catch(() => {});
  await Promise.all([
    page.waitForLoadState("networkidle"),
    page.locator('input[name="sendit"]').click(),
  ]);

  const body = (await page.locator("body").innerText()).replace(/\s+/g, " ");
  const err = body.match(/Erreur[^.]{0,160}/i);
  if (err) throw new Error(`${f.as} : ${err[0]}`);

  // Relire : le formulaire sait échouer sans le dire.
  if (!(await filesIn(page, sectionId)).has(f.as)) {
    throw new Error(`${f.as} : absent du répertoire après téléversement`);
  }
  return "versé";
}

const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ locale: "fr-FR" });
const page = await context.newPage();

try {
  await login.doAdminLogin({
    page,
    dolibarrAddress,
    adminCredentials: {
      username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined",
      password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined",
    },
  });

  // Les parents d'abord : le select ne peut proposer que ce qui existe déjà.
  const ordered = [...DIRS].sort((a, b) => (a.parent ? 1 : 0) - (b.parent ? 1 : 0));
  for (const d of ordered) {
    const state = await createDir(page, d.label, d.parent, d.desc);
    console.log(`  ${state === "créé" ? "+" : "="} ${pathOf(d.label)} (${state})`);
  }

  console.log("");
  const sections = await readTree(page);
  const tmp = await Deno.makeTempDir({ prefix: "arcodange-ged-" });
  try {
    for (const f of FILES) {
      const dir = sections.get(pathOf(f.dir));
      if (!dir) throw new Error(`répertoire ${pathOf(f.dir)} introuvable dans l'arbre`);
      const state = await upload(page, f, dir.id, tmp);
      console.log(`  ${state === "versé" ? "+" : "="} ${pathOf(f.dir)}/${f.as} (${state})`);
    }
  } finally {
    await Deno.remove(tmp, { recursive: true }).catch(() => {});
  }

  // Vérification finale par les NOMS, jamais par un compteur : un compteur juste
  // peut recouvrir deux exemplaires d'une pièce et l'absence d'une autre.
  const bad: string[] = [];
  const extra: string[] = [];
  for (const d of DIRS) {
    const path = pathOf(d.label);
    const sec = sections.get(path);
    if (!sec) {
      bad.push(`${path} : absent de l'arbre`);
      continue;
    }
    const present = await filesIn(page, sec.id);
    const wanted = FILES.filter((f) => f.dir === d.label).map((f) => f.as);
    for (const w of wanted) if (!present.has(w)) bad.push(`${path}/${w} : absent`);
    for (const p of present) if (!wanted.includes(p)) extra.push(`${path}/${p}`);
  }
  if (extra.length) {
    console.warn("\nPièces présentes hors manifeste (à examiner, rien n'a été supprimé) :");
    for (const e of extra) console.warn(`   ${e}`);
  }
  if (bad.length) {
    console.error("\nVERSEMENT INCOMPLET :");
    for (const b of bad) console.error(`   ${b}`);
    Deno.exit(1);
  }
  console.log(`\n${DIRS.length} répertoires, ${FILES.length} pièces — chaque pièce vérifiée par son nom dans la GED.`);
} finally {
  await context.close();
  await browser.close();
}
