// src/lib/tours/catalog.test.ts — invariants du catalogue de tours.
// Ces tests gardent les contraintes dures du plan : routes réelles, aucun tour
// sur un écran « En construction », ancrage par data-tour uniquement.

import { describe, expect, it } from "bun:test";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { isUnderConstruction } from "@/lib/feature-status";
import { TOUR_IDS, TOURS, tourForRoute } from "./catalog";

/**
 * Le contrat d'ancrage, figé NOM PAR NOM.
 *
 * Ces dix valeurs sont ce que la tâche 4 doit poser en `data-tour="…"` dans les
 * pages. Un simple compte de points par tour ne suffisait pas : il laissait
 * renommer, déplacer ou intervertir une ancre sans qu'un test bronche, alors
 * que la couverture se vérifie en comptant DIX occurrences dans le code.
 *
 * Toute évolution de cette liste est une décision de produit — elle se prend
 * ici, en clair, et pas par effet de bord d'une retouche du catalogue.
 */
const ANCRES_ATTENDUES = [
  // reservoir
  "reservoir-table",
  "keywords-import",
  "keywords-sync",
  "reservoir-sources",
  // recherche
  "research-table",
  "research-gsc-columns",
  "research-matrix",
  // planning
  "planning-table",
  "planning-readiness",
  "planning-dispatch",
] as const;

describe("catalogue des tours", () => {
  it("déclare les trois tours de la v1, un par identifiant", () => {
    expect(TOURS.map((t) => t.id)).toEqual([...TOUR_IDS]);
    expect(TOUR_IDS).toEqual(["reservoir", "recherche", "planning"]);
  });

  it("rattache chaque tour à une route distincte", () => {
    const routes = TOURS.map((t) => t.route);
    expect(new Set(routes).size).toBe(routes.length);
  });

  // On INTERROGE la source de vérité (src/lib/feature-status) au lieu de
  // recopier sa liste. Une recopie dérive en silence : quand une route passe
  // « En construction », ce test doit rougir tout seul, sinon un tour tournerait
  // par-dessus l'écran de remplacement servi en production par le proxy.
  // `isUnderConstruction` gère aussi le cas de la route exacte sans barre
  // finale, qu'une comparaison de préfixe « /content/ » ratait.
  it("ne cible aucune route « En construction »", () => {
    for (const tour of TOURS) {
      expect(isUnderConstruction(tour.route)).toBe(false);
    }
  });

  it("ne déclare que des routes qui existent vraiment", () => {
    for (const tour of TOURS) {
      const page = new URL(
        `../../app/(app)${tour.route}/page.tsx`,
        import.meta.url,
      );
      expect(existsSync(page)).toBe(true);
    }
  });

  it("porte trois à quatre points par tour", () => {
    for (const tour of TOURS) {
      expect(tour.steps.length).toBeGreaterThanOrEqual(3);
      expect(tour.steps.length).toBeLessThanOrEqual(4);
    }
  });

  it("fige les DIX ancres du contrat, nommées et dans l'ordre du catalogue", () => {
    const anchors = TOURS.flatMap((t) => t.steps.map((s) => s.anchor));
    expect(anchors).toEqual([...ANCRES_ATTENDUES]);
    // Le compte est dit à part : c'est le nombre que la tâche 4 vérifie en
    // comptant les `data-tour` posés dans les pages.
    expect(anchors).toHaveLength(10);
  });

  it("ancre chaque point sur un nom d'attribut data-tour, jamais un sélecteur", () => {
    for (const tour of TOURS) {
      for (const step of tour.steps) {
        // Un nom d'ancre : lettres, chiffres et tirets. Un `.`, `#`, `[` ou une
        // espace trahirait un sélecteur CSS, qu'un refactor de style casserait.
        expect(step.anchor).toMatch(/^[a-z0-9-]+$/);
        expect(step.key).toMatch(/^[a-zA-Z0-9]+$/);
      }
    }
  });

  /**
   * Les ancres recouvertes par la bande de pilotage collante de leur page,
   * figées NOM PAR NOM comme les dix précédentes.
   *
   * Ce drapeau est la seule chose qui rend ces trois points LISIBLES : sans lui,
   * la découpe de l'overlay ne montre que le fond de la bande, sous une bulle
   * qui décrit ce qui est masqué. Aucun test d'exécution ne peut voir un halo —
   * c'est donc ici, et seulement ici, que la liste se garde.
   *
   * Les deux bords comptent autant que la liste :
   * - `keywords-import` et `keywords-sync` vivent DANS la bande, où elles
   *   restent épinglées et visibles ; les déclarer ici brouillerait ce que le
   *   drapeau veut dire ;
   * - `planning-dispatch` est bel et bien recouverte, mais c'est une LIGNE de
   *   tableau pleine largeur : la remonter tranche la barre d'outils en deux
   *   (vérifié en navigateur). Elle relève du correctif par défilement, resté
   *   en dette.
   */
  const ANCRES_SOUS_LA_BANDE = [
    "research-gsc-columns",
    "planning-readiness",
    "reservoir-sources",
  ];

  it("déclare sous la bande collante EXACTEMENT les ancres concernées", () => {
    const sousLaBande = TOURS.flatMap((t) =>
      t.steps.filter((s) => s.underStickyBand).map((s) => s.anchor),
    );
    expect(sousLaBande.toSorted()).toEqual(ANCRES_SOUS_LA_BANDE.toSorted());
  });

  it("n'admet que `true` comme valeur du drapeau, jamais `false`", () => {
    // Un `underStickyBand: false` explicite dirait la même chose que l'absence
    // du champ, avec deux façons de l'écrire : le type l'interdit, ce test le
    // constate sur le catalogue réel.
    for (const tour of TOURS) {
      for (const step of tour.steps) {
        expect([true, undefined]).toContain(step.underStickyBand);
      }
    }
  });

  it("n'utilise jamais deux fois la même ancre", () => {
    const anchors = TOURS.flatMap((t) => t.steps.map((s) => s.anchor));
    expect(new Set(anchors).size).toBe(anchors.length);
  });

  it("donne des clés i18n distinctes à l'intérieur d'un même tour", () => {
    for (const tour of TOURS) {
      const keys = tour.steps.map((s) => s.key);
      expect(new Set(keys).size).toBe(keys.length);
    }
  });
});

/**
 * Le SECOND BORD du contrat d'ancrage : les écrans.
 *
 * Le catalogue fige les dix noms, mais une ancre absente du document est
 * filtrée SILENCIEUSEMENT et sans effet de bord (`resolveTourSteps`) — un choix
 * délibéré, qui fait qu'un tour rétrécit au lieu de casser. Le revers est qu'un
 * renommage, un déplacement, ou la disparition d'un élément d'enrobage rétrécit
 * un tour sans test, sans analyse statique et sans erreur d'exécution : jusqu'à
 * ce fichier, la pose des dix ancres n'était vérifiée qu'À LA MAIN.
 *
 * On cherche donc le NOM entre guillemets, pas le motif `data-tour="…"` : deux
 * des dix ancres sont posées conditionnellement (`row.index === 0 ? … :
 * undefined`) et l'une d'elles transite par une propriété de composant
 * (`<SourceDots dataTour={…} />`). Exiger une occurrence littérale par nom
 * interdirait ces trois formes, qui sont les bonnes.
 */
describe("pose des ancres dans les écrans", () => {
  const RACINE = new URL("../../components/keywords/", import.meta.url);

  function fichiers(dir: URL): URL[] {
    return readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
      const enfant = new URL(e.name + (e.isDirectory() ? "/" : ""), dir);
      if (e.isDirectory()) return fichiers(enfant);
      return e.name.endsWith(".tsx") ? [enfant] : [];
    });
  }

  const ECRANS = fichiers(RACINE).map((url) => ({
    chemin: url.pathname,
    code: readFileSync(url, "utf8"),
  }));

  it("lit bien les écrans du groupe mots-clés", () => {
    // Garde de la garde : un chemin devenu faux rendrait tous les tests
    // ci-dessous vacants au lieu de rouges.
    expect(ECRANS.length).toBeGreaterThan(10);
  });

  it("repose sur l'attribut data-tour toute propriété dataTour reçue", () => {
    // Chercher le NOM suffit à voir un renommage, mais pas à voir la PLOMBERIE
    // coupée : `reservoir-sources` transite par `<SourceDots dataTour={…} />`,
    // et le composant qui la reçoit pourrait cesser de la reposer sur
    // l'attribut sans qu'aucun nom ne bouge. Ce test-ci ferme ce trou.
    const receveurs = ECRANS.filter((f) => /dataTour\??\s*:/.test(f.code));
    expect(receveurs.map((f) => f.chemin)).not.toEqual([]);
    for (const f of receveurs) {
      expect(f.code).toContain("data-tour={dataTour}");
    }
  });

  for (const anchor of ANCRES_ATTENDUES) {
    it(`pose l'ancre ${anchor}`, () => {
      const porteurs = ECRANS.filter((f) => f.code.includes(`"${anchor}"`));
      expect(porteurs.map((f) => f.chemin)).not.toEqual([]);
      // Le nom doit être posé EN TANT QU'ANCRE : soit l'attribut lui-même,
      // soit la propriété qui le transporte jusqu'à l'attribut.
      for (const f of porteurs) {
        expect(
          f.code.includes("data-tour") || f.code.includes("dataTour"),
        ).toBe(true);
      }
    });
  }
});

describe("tourForRoute", () => {
  it("retrouve le tour d'une route déclarée", () => {
    expect(tourForRoute("/keywords/reservoir")?.id).toBe("reservoir");
    expect(tourForRoute("/keywords/planning")?.id).toBe("planning");
  });

  it("ne rend rien sur une route sans tour", () => {
    expect(tourForRoute("/keywords/overview")).toBeNull();
    expect(tourForRoute("/content/planning")).toBeNull();
  });

  it("rend TOUJOURS le même objet pour une route donnée", () => {
    // Propriété portante, et non détail d'implémentation : `TourRunner` met le
    // tour dans les dépendances de son effet, à la place de l'objet de
    // progression reconstruit à chaque rendu serveur. Rendre une copie
    // (`{ ...tour }`, `structuredClone`) relancerait l'effet à chaque rendu, et
    // son nettoyage fermerait le tour ouvert — la page Planning rafraîchit la
    // route à onze endroits.
    expect(tourForRoute("/keywords/reservoir")).toBe(
      tourForRoute("/keywords/reservoir"),
    );
    expect(tourForRoute("/keywords/reservoir")).toBe(TOURS[0]);
  });

  it("exige une correspondance exacte, pas un préfixe", () => {
    expect(tourForRoute("/keywords/reservoir/")).toBeNull();
    expect(tourForRoute("/keywords/reservoir/42")).toBeNull();
  });
});
