// src/lib/tours/plan.ts — TOUTES les décisions du moteur de rendu, en pur.
//
// Le composant `TourRunner` ne doit contenir que du câblage : lecture du DOM,
// appels à driver.js, abonnements. Toute condition non triviale vit ici, parce
// que l'environnement de test n'a ni DOM ni simulation de clic — un `if` écrit
// dans le `.tsx` est un `if` que personne ne vérifiera jamais.
//
// Ce module est PUR : pas d'accès à `document`, pas d'import runtime de
// `./progress` (server-only). La présence des ancres et le fait que la page se
// déclare non vide arrivent en paramètres, sous forme de prédicats.
import type { TourDef, TourId } from "./catalog";
import type { TourStatus } from "./progress";
import { startRuleVerdict } from "./should-start";

/** Ancrage par attribut UNIQUEMENT : un refactor de style ne doit pas casser
 *  le tutoriel en silence. Ni classe utilitaire, ni sélecteur de structure. */
export function anchorSelector(anchor: string): string {
  return `[data-tour="${anchor}"]`;
}

/** Marqueur que la page pose elle-même quand elle a quelque chose à montrer. */
export const CONTENT_SELECTOR = '[data-tour-has-content="true"]';

/**
 * Attribut que le runner pose sur l'élément mis en avant, et SEULEMENT le temps
 * du point, quand celui-ci est déclaré sous une bande collante
 * (`TourStep.underStickyBand`). La feuille de style globale lui donne
 * l'ordre de peinture qui manque à `driver.css` : sans lui, la découpe de
 * l'overlay ne révèle que le fond de la bande.
 *
 * Un attribut, jamais une classe utilitaire : c'est la même règle que
 * l'ancrage — un remaniement de style ne doit pas casser le tutoriel en
 * silence.
 */
export const TOUR_LIFT_ATTRIBUTE = "data-tour-lift";

/** Le même, en sélecteur — pour le balayage de sûreté au démontage. */
export const TOUR_LIFT_SELECTOR = "[data-tour-lift]";

export type ResolvedStep = {
  anchor: string;
  key: string;
  /** Sélecteur prêt à être passé à driver.js. */
  element: string;
  /**
   * Faut-il remonter cet élément au-dessus de la bande collante pendant qu'il
   * est éclairé ? Décidé ICI, à partir du catalogue, et jamais dans le
   * composant : une comparaison écrite dans le `.tsx` est hors d'atteinte des
   * tests.
   */
  lift: boolean;
};

/**
 * Filtre les points dont l'ancre est absente du document, en préservant l'ordre
 * du catalogue (jamais celui du document).
 *
 * L'absence d'une ancre est SILENCIEUSE et sans effet de bord : le tour rétrécit,
 * il n'échoue pas. C'est ce qui doit arriver quand une ancre est posée sur la
 * mauvaise cible — typiquement `keywords-import`, `keywords-sync` et
 * `planning-dispatch`, qui doivent viser un déclencheur et non le contenu d'une
 * boîte de dialogue (invisible tant qu'elle n'est pas ouverte).
 */
export function resolveTourSteps(
  tour: TourDef,
  isAnchorPresent: (anchor: string) => boolean,
): ResolvedStep[] {
  return tour.steps
    .filter((s) => isAnchorPresent(s.anchor))
    .map((s) => ({
      anchor: s.anchor,
      key: s.key,
      element: anchorSelector(s.anchor),
      lift: s.underStickyBand === true,
    }));
}

/**
 * Demande de relance émise par le bouton « revoir ».
 *
 * Elle porte l'identifiant du tour visé, JAMAIS un simple booléen : c'est le
 * module pur qui doit décider si elle concerne le tour de la page où
 * l'utilisateur se trouve. Tant que la comparaison vivait dans le composant,
 * cette décision était hors de portée des tests — on pouvait y rétablir une
 * relance globale, qui fait ignorer le statut enregistré de TOUS les autres
 * tours, sans que rien ne rougisse.
 */
export type TourReplayRequest = {
  tourId: string;
  /**
   * Numéro d'ordre strictement croissant. Il distingue deux demandes
   * successives du MÊME tour, que l'identifiant seul confond, et c'est lui que
   * la session marque comme consommé.
   */
  nonce: number;
};

/**
 * Ce que le tutoriel a déjà fait pendant CETTE session de navigation — c'est-à-
 * dire ce que la base ne sait pas encore, ou pas encore relu.
 */
export type TourSession = {
  /**
   * Tours lancés pendant cette session — UN ENSEMBLE, jamais un créneau unique.
   *
   * Un seul créneau semble suffire tant qu'on regarde une page à la fois : on
   * n'y démarre qu'un tour. Il ne suffit pas dès qu'on en regarde deux, et
   * c'est le cas nominal — les trois tours sont sur des routes sœurs.
   *
   * La progression lue au serveur descend du layout (app). Une navigation
   * cliente entre routes sœurs NE RE-REND PAS ce layout : le statut reçu par le
   * composant reste celui du chargement initial, pour toute la session (voir
   * `sessionAfterTeardown`). C'est donc cette mémoire, et elle seule, qui
   * empêche un tour terminé de se rouvrir — un créneau unique la faisait
   * ÉCRASER par le tour de la page voisine, et un tour terminé se rejouait à
   * chaque aller-retour.
   */
  startedTourIds: readonly string[];
  /** Nonce de la dernière demande de relance déjà servie. */
  consumedReplayNonce: number;
};

export const EMPTY_TOUR_SESSION: TourSession = {
  startedTourIds: [],
  consumedReplayNonce: 0,
};

export type PlanTourInput = {
  /** Tour rattaché à la route courante, `null` hors catalogue. */
  tour: TourDef | null;
  /** Statut enregistré en base pour ce tour, `undefined` si jamais vu. */
  status: TourStatus | undefined;
  /** Demande de relance en attente, `null` si l'utilisateur n'a rien demandé. */
  replayRequest: TourReplayRequest | null;
  session: TourSession;
  anotherTourRunning: boolean;
  isAnchorPresent: (anchor: string) => boolean;
  /** La page a-t-elle posé `data-tour-has-content="true"` ? */
  pageDeclaresContent: boolean;
};

/**
 * Une demande de relance est-elle à servir POUR CE TOUR, maintenant ?
 *
 * Deux conditions, et les deux ont déjà manqué :
 * 1. la PORTÉE — la demande nomme ce tour-ci. Une relance globale relancerait
 *    n'importe quel tour de n'importe quelle page ;
 * 2. la CONSOMMATION — la demande n'a pas déjà été servie. Sans ce test, une
 *    demande restait vraie pour toute la session : l'utilisateur cliquait
 *    « revoir » une fois, et le tour se rouvrait à CHAQUE retour sur la page,
 *    sans qu'on l'ait redemandé.
 */
export function isReplayRequested(
  request: TourReplayRequest | null,
  session: TourSession,
  tourId: string,
): boolean {
  if (request === null) return false;
  if (request.tourId !== tourId) return false;
  return request.nonce > session.consumedReplayNonce;
}

/**
 * Session APRÈS le démarrage de `tourId` : le tour REJOINT l'ensemble des tours
 * lancés (il n'en chasse aucun autre), et la demande de relance qui le visait
 * est CONSOMMÉE.
 *
 * L'ajout est idempotent : une reprise du même tour ne le fait pas figurer deux
 * fois, ce qui garantit que la restitution ci-dessous n'a jamais à compter les
 * occurrences.
 */
export function sessionAfterStart(
  session: TourSession,
  tourId: string,
  replayRequest: TourReplayRequest | null,
): TourSession {
  return {
    startedTourIds: session.startedTourIds.includes(tourId)
      ? session.startedTourIds
      : [...session.startedTourIds, tourId],
    consumedReplayNonce:
      replayRequest !== null && replayRequest.tourId === tourId
        ? replayRequest.nonce
        : session.consumedReplayNonce,
  };
}

/**
 * Session APRÈS le démontage du tour, la transition symétrique de la
 * précédente. `leftUnread` dit que l'utilisateur n'a ni terminé ni rejeté quoi
 * que ce soit — soit parce que driver.js était ENCORE à l'écran, soit parce
 * que la bibliothèque, chargée à la demande, n'était même pas arrivée. Les
 * trois cas sont tranchés par `createTourLaunch` (./launch.ts) ; ici, les deux
 * premiers se valent : le tour n'a pas eu lieu.
 *
 * Les deux champs de la session NE SE COMPORTENT PAS PAREIL, et les confondre
 * était un défaut visible à l'écran :
 *
 * - `startedTourIds` est RENDU — mais UNIQUEMENT de ce que CE démarrage y a
 *   ajouté. Il n'existe que pour éviter d'empiler deux fois le même tour dans
 *   une même visite ; si le tour n'a pas été lu, il n'a pas eu lieu. Retirer
 *   davantage (revenir en bloc à l'ensemble d'avant conviendrait ici, mais dit
 *   moins) rendrait rejouable un tour lu PLUS TÔT dans la session : une reprise
 *   abandonnée effacerait le souvenir de la lecture d'origine. Deux situations
 *   en dépendent :
 *   • le DOUBLE MONTAGE du mode strict (React, en développement) — le premier
 *     passage démarre le tour, le nettoyage le détruit, le second repart. Sans
 *     restitution, le second passage n'a plus rien à faire : charger
 *     directement une route à tour n'affiche RIEN, et pour toute la session ;
 *   • QUITTER UNE PAGE NE CONSOMME PAS UN TOUR — un tour qu'on n'a pas eu le
 *     temps de lire est reproposé à la visite suivante.
 *
 * - `consumedReplayNonce` n'est JAMAIS rendu. Une reprise est un geste
 *   explicite : l'utilisateur a cliqué « revoir », le tour s'est affiché, la
 *   demande est servie. Quitter la page n'est pas un geste explicite et ne
 *   redemande rien. Tant que la restitution rendait aussi cette marque, une
 *   reprise abandonnée redevenait fraîche et le tour se rouvrait à CHAQUE
 *   retour sur la page, pour toute la session (vu au navigateur, 2026-07-31).
 *
 * Rendre `startedTourId` sans rendre la marque de consommation est cohérent :
 * le double montage du mode strict n'a lieu qu'au montage initial, où aucune
 * demande de reprise n'existe encore (elle naît d'un clic, donc plus tard).
 *
 * Quand le tour a bien été terminé ou rejeté, driver.js s'est détruit LUI-MÊME
 * depuis ses rappels : `leftUnread` est faux, et tout est confirmé. C'est la
 * session, et non le statut lu au serveur, qui empêche le tour de se rouvrir au
 * retour sur la page — l'écriture en base ne revalide aucune route, donc la
 * progression reçue par le composant reste périmée jusqu'au prochain rendu.
 */
export function sessionAfterTeardown(
  before: TourSession,
  afterStart: TourSession,
  leftUnread: boolean,
): TourSession {
  return {
    startedTourIds: leftUnread
      ? afterStart.startedTourIds.filter((id) =>
          before.startedTourIds.includes(id),
        )
      : afterStart.startedTourIds,
    consumedReplayNonce: afterStart.consumedReplayNonce,
  };
}

/**
 * Verdict NOMMÉ plutôt que booléen : un `false` recouvrirait deux situations
 * opposées, et rien n'empêcherait un lecteur pressé d'enregistrer « rejeté »
 * dans une branche de report — ce qui ferait perdre définitivement son tour à
 * tout utilisateur arrivant sur un écran encore vide.
 *
 * Les préfixes disent la nature du refus :
 * - `defer-*` : REPORT. L'écran n'est pas prêt, le tour sera reproposé.
 * - les autres : rien à faire, le tour a déjà eu sa chance ou n'a pas lieu d'être.
 *
 * AUCUN de ces verdicts n'autorise une écriture. Le statut ne s'écrit QUE sur
 * une fin ou un rejet explicites de l'utilisateur, depuis les rappels de
 * driver.js — jamais depuis une décision de démarrage.
 */
/**
 * REPORT : la page n'est pas encore prête. Le tour sera reproposé tel quel à la
 * prochaine occasion — il ne s'est RIEN passé.
 */
export type TourDeferral =
  /** La page ne se déclare pas non vide. */
  | { kind: "defer-empty" }
  /** L'ancre du premier point n'est pas (encore) dans le document. */
  | { kind: "defer-no-anchor" };

/**
 * REFUS : le tour n'a pas lieu d'être ici et maintenant. Rien à reproposer dans
 * l'immédiat — mais rien à enregistrer non plus (voir ci-dessus).
 */
export type TourRefusal =
  /** La route n'a pas de tour. */
  | { kind: "no-tour" }
  /** Un tour est déjà à l'écran : on n'en empile pas un second. */
  | { kind: "busy" }
  /** Déjà joué pendant cette session de navigation. */
  | { kind: "already-started" }
  /** Terminé ou rejeté lors d'une visite précédente. */
  | { kind: "already-seen" };

export type TourVerdict =
  | { kind: "start"; tourId: TourId; steps: ResolvedStep[] }
  | TourDeferral
  | TourRefusal;

export function planTour(input: PlanTourInput): TourVerdict {
  const tour = input.tour;
  if (!tour) return { kind: "no-tour" };

  // Un tour en cours l'emporte sur tout, y compris sur une relance explicite :
  // deux overlays driver.js simultanés se voleraient le focus. C'est le SEUL
  // endroit où la question se pose (voir ./should-start.ts) : ce module est le
  // seul à recevoir l'état d'une instance driver.js vivante.
  if (input.anotherTourRunning) return { kind: "busy" };

  const replayRequested = isReplayRequested(
    input.replayRequest,
    input.session,
    tour.id,
  );

  // Une relance explicite passe outre le statut enregistré ET le fait de
  // l'avoir déjà joué ici — sans rien effacer en base.
  if (!replayRequested && input.session.startedTourIds.includes(tour.id)) {
    return { kind: "already-started" };
  }

  // Règle de déclenchement partagée (tâche 2), jamais RÉÉCRITE ici : ce module
  // lui passe le vrai `pageDeclaresContent` au lieu de refaire le test, sans
  // quoi la précédence « écran vide avant statut » vivrait à deux endroits et
  // ne serait asservie qu'à un seul.
  const rule = startRuleVerdict({
    status: replayRequested ? undefined : input.status,
    hasContent: input.pageDeclaresContent,
  });

  // Les deux REPORTS vont ensemble, et avant tout refus : ils disent la même
  // chose — la page n'est pas encore prête. `defer-empty` passe en premier
  // parce que la règle partagée place l'écran vide avant tout le reste.
  // Le seul cas qui distingue cette ligne de son absence est celui où l'écran
  // est vide ET l'ancre absente — un tableau à zéro ligne rend souvent les
  // deux vraies en même temps. Il est asservi, nommément, par plan.test.ts.
  if (rule.kind === "defer-empty") return rule;

  const firstAnchor = tour.steps[0]?.anchor;
  if (firstAnchor === undefined || !input.isAnchorPresent(firstAnchor)) {
    return { kind: "defer-no-anchor" };
  }

  // Ne reste que `eligible` ou `already-seen`.
  if (rule.kind !== "eligible") return rule;

  // L'ancre du premier point existe, donc au moins un point survit : ce tableau
  // ne peut pas être vide.
  return {
    kind: "start",
    tourId: tour.id,
    steps: resolveTourSteps(tour, input.isAnchorPresent),
  };
}

/**
 * Ce que devient une demande de reprise que `planTour` n'a PAS honorée.
 */
export type RefusedReplay = {
  /** Session à retenir : la demande y est marquée SERVIE. */
  session: TourSession;
  /** Le dire à l'utilisateur : il a cliqué, et rien ne s'est ouvert. */
  notify: boolean;
};

/**
 * Une demande de reprise refusée est CONSOMMÉE, et annoncée.
 *
 * Le nonce n'était marqué qu'au démarrage. Sur un écran encore vide, « revoir
 * ce tour » rendait donc un report — correct — mais SILENCIEUX et PERSISTANT,
 * deux défauts pour le prix d'un :
 * - silencieux : le bouton ne faisait rien et ne disait rien, alors que la spec
 *   en fait la porte de sortie du système ;
 * - persistant : la demande survivait au refus. Après un import, le premier
 *   aller-retour venu relançait l'effet et le tour s'ouvrait sans qu'on vienne
 *   de le demander.
 *
 * Une seule règle, pour tous les refus : une demande qui n'ouvre pas de tour
 * est servie quand même. `busy` y tombe aussi — l'overlay de driver.js couvre
 * le bouton, donc le cas est en pratique inatteignable, et l'utilisateur peut
 * de toute façon recliquer. Les deux autres refus (`already-*`) ne peuvent pas
 * se produire face à une demande fraîche : `planTour` la fait passer outre.
 */
export function refuseReplay(input: {
  verdict: TourVerdict;
  session: TourSession;
  request: TourReplayRequest | null;
  tour: TourDef | null;
}): RefusedReplay {
  const { verdict, session, request, tour } = input;
  if (verdict.kind === "start" || tour === null || request === null) {
    return { session, notify: false };
  }
  // La PORTÉE et la CONSOMMATION, exactement comme au démarrage : on ne touche
  // pas à la demande qui nomme le tour voisin (elle sera servie là-bas), et on
  // n'annonce pas deux fois celle qui l'a déjà été — le double montage du mode
  // strict rejoue ce chemin.
  if (!isReplayRequested(request, session, tour.id)) {
    return { session, notify: false };
  }
  return {
    session: { ...session, consumedReplayNonce: request.nonce },
    notify: true,
  };
}

export type DriveStepSpec = {
  element: string;
  popover: { title: string; description: string };
  /** Report de `ResolvedStep.lift` : le runner n'a plus qu'à le poser. */
  lift: boolean;
};

/**
 * Traduction d'une clé composée à l'exécution, sous le namespace `Tour`. Les
 * valeurs sont des placeholders ICU ; ce module ne connaît ni next-intl ni
 * React.
 */
export type TourTranslate = (
  key: string,
  values?: Record<string, string>,
) => string;

/**
 * Marqueurs que driver.js remplace LUI-MÊME dans `progressText`, recopiés de sa
 * source (`dist/driver.js.mjs`, 1.8.0) :
 *
 *   (progressText || "{{current}} of {{total}}")
 *     .replace("{{current}}", …).replace("{{total}}", …)
 *
 * Ils ne peuvent pas être écrits tels quels dans nos catalogues : next-intl
 * parse le message en ICU, où `{{current}}` n'est pas un placeholder valide.
 * Les catalogues portent donc `{current}` / `{total}`, et ce sont les marqueurs
 * de driver.js qui leur sont passés EN VALEUR — next-intl compose le gabarit,
 * driver.js le remplit.
 */
export const DRIVER_PROGRESS_MARKERS = {
  current: "{{current}}",
  total: "{{total}}",
} as const;

/**
 * Le décor que driver.js écrit lui-même autour de nos textes : le compteur de
 * progression et les trois boutons.
 *
 * Non renseignés, ils restent en ANGLAIS quelle que soit la langue de
 * l'application (« 1 of 4 », « Previous », « Next », « Done » sont codés en dur
 * dans la bibliothèque). Aucun test de parité ne pouvait le voir : ces libellés
 * n'étaient dans aucun catalogue.
 */
export type DriverLabels = {
  progressText: string;
  nextBtnText: string;
  prevBtnText: string;
  doneBtnText: string;
};

/** Les quatre libellés de `DriverLabels`, tirés de `Tour.ui.*`. */
export function buildDriverLabels(translate: TourTranslate): DriverLabels {
  return {
    progressText: translate("ui.progress", { ...DRIVER_PROGRESS_MARKERS }),
    nextBtnText: translate("ui.next"),
    prevBtnText: translate("ui.previous"),
    doneBtnText: translate("ui.done"),
  };
}

/**
 * Compose les clés i18n `Tour.<tourId>.<key>.title|body`. La traduction arrive
 * en paramètre : ce module ne connaît ni next-intl ni React.
 */
export function buildDriveSteps(
  tourId: TourId,
  steps: readonly ResolvedStep[],
  translate: TourTranslate,
): DriveStepSpec[] {
  return steps.map((s) => ({
    element: s.element,
    popover: {
      title: translate(`${tourId}.${s.key}.title`),
      description: translate(`${tourId}.${s.key}.body`),
    },
    lift: s.lift,
  }));
}
