import { useTranslation } from '@/hooks/use-translation';
import type { SharedData } from '@/types';
import { router, usePage } from '@inertiajs/react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
    TOUR_DIALOG_MAX_WIDTH,
    TOUR_STORAGE_KEY,
    anchorSelector,
    findTourForComponent,
    isTourCompleted,
    needsToggle,
    resolveTourSteps,
    stepTranslationKeys,
    tourLaunchOptions,
} from './registry';
import { TOURS } from './tours';
import type { AppTour } from './types';

/**
 * Pilote la visite guidée de la page courante.
 *
 * QUATRE CONTRAINTES ONT DICTÉ CE FICHIER — les toucher casse quelque chose :
 *
 * 1. **UN SEUL CLIENT, POUR TOUTE LA DURÉE DE VIE DE L'ONGLET.** C'est la plus
 *    importante, et la moins évidente. Le constructeur de `TourGuideClient`
 *    ajoute un `<div class="tg-dialog">` à `document.body` — et la bibliothèque
 *    remplit ensuite son contenu avec `document.getElementById('tg-dialog-title')`
 *    / `'tg-dialog-body'` / `'tg-dialog-dots'` (voir `core/dialog.ts`), c'est-à-dire
 *    par des IDENTIFIANTS GLOBAUX, pas relativement à son propre élément.
 *
 *    Deux clients = deux dialogues portant les mêmes identifiants. Le second
 *    écrit alors son texte dans le PREMIER (l'ancien, invisible) et affiche le
 *    sien, resté vide : à la réouverture, l'utilisateur voit un dialogue sans
 *    titre, sans texte et sans pastilles, avec seulement les boutons. C'est
 *    exactement le bug observé. `handleTourStart` a le même travers avec
 *    `waitForElm('.tg-dialog')`.
 *
 *    Corollaire : ne jamais construire un client « jetable », fût-ce pour
 *    interroger `isFinished` — d'où `isTourCompleted()` qui lit le localStorage.
 *
 * 2. `@sjmc11/tourguidejs` est publié en **UMD** et son bundle référence `self`
 *    dès l'évaluation du module. Un `import` en tête de fichier ferait donc
 *    tomber `npm run build:ssr`, où `self` n'existe pas. D'où l'`import()`
 *    dynamique, à l'intérieur d'un effet — donc jamais côté serveur.
 *
 * 3. Une navigation Inertia doit fermer la visite en cours, sinon le backdrop
 *    survit à l'écran qu'il surlignait. On appelle `exit()` — on ne DÉTRUIT pas
 *    le client (voir contrainte 1).
 *
 * 4. Le DOM doit être peint avant de mesurer les ancres. TourGuide positionne
 *    son dialogue à partir de `getBoundingClientRect()` ; appelé trop tôt, tout
 *    se superpose en haut à gauche.
 */

/** Type minimal de ce qu'on consomme du client TourGuide — évite un `any`. */
interface TourStepInput {
    title: string;
    content: string;
    target: string;
    order: number;
    fixed?: boolean;
    group: string;
    beforeEnter?: () => Promise<void>;
    beforeLeave?: () => Promise<void>;
}

/** Laisse React committer son rendu avant qu'on mesure quoi que ce soit. */
function nextPaint(): Promise<void> {
    return new Promise((resolve) => {
        requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
    });
}

/**
 * Ouvre (ou referme) le menu porté par une ancre, en cliquant son déclencheur.
 *
 * Radix n'expose aucune API impérative : un menu s'ouvre en cliquant son
 * déclencheur, et l'état se lit sur `data-state`. On attend ensuite un rendu
 * complet, parce que TourGuide positionne son dialogue juste après et doit
 * mesurer un DOM stabilisé.
 */
async function setAnchorOpen(anchor: string, shouldBeOpen: boolean): Promise<void> {
    const trigger = document.querySelector<HTMLElement>(anchorSelector(anchor));

    if (!trigger) {
        return;
    }

    if (needsToggle(trigger.getAttribute('data-state'), shouldBeOpen)) {
        trigger.click();
        await nextPaint();
    }
}

/**
 * Ancres ouvertes par la visite en cours.
 *
 * Nécessaire parce que `beforeLeave` ne se déclenche QUE sur un changement
 * d'étape : quitter la visite par la croix, par Échap ou par « Terminer »
 * n'appelle pas `visitStep`, et le menu resterait ouvert derrière une page
 * redevenue normale.
 */
const openedAnchors = new Set<string>();

async function closeAllOpenedAnchors(): Promise<void> {
    const anchors = [...openedAnchors];
    openedAnchors.clear();

    for (const anchor of anchors) {
        await setAnchorOpen(anchor, false);
    }
}

interface TourClientOptions {
    steps?: TourStepInput[];
    nextLabel?: string;
    prevLabel?: string;
    finishLabel?: string;
    dialogMaxWidth?: number;
    [key: string]: unknown;
}

/**
 * Largeur utile de l'écran, lue au LANCEMENT d'une visite — jamais au
 * chargement du module, que le rendu serveur évalue sans `window`.
 *
 * `clientWidth` plutôt que `innerWidth` : c'est la boîte dans laquelle
 * floating-ui ramène le dialogue (barre de défilement verticale exclue sur
 * un poste de bureau).
 */
function viewportWidth(): number {
    return document.documentElement.clientWidth || window.innerWidth;
}

interface TourClient {
    isVisible: boolean;
    start(group?: string): Promise<unknown>;
    exit(): Promise<unknown>;
    setOptions(options: TourClientOptions): Promise<unknown>;
    onFinish: (callback: () => void) => void;
    onAfterExit: (callback: () => void) => void;
    /** Remesure la cible et repose le cadre et le dialogue. */
    updatePositions(): Promise<unknown>;
}

/**
 * Repositionnement sur le défilement d'un conteneur INTERNE.
 *
 * La bibliothèque écoute `window.scroll` sans `capture`. Or l'événement
 * `scroll` ne remonte pas : celui d'un tableau qui défile horizontalement
 * n'atteint jamais cet écouteur, et le cadre reste sur place pendant que la
 * colonne visée s'en va. On complète donc avec un écouteur en phase de
 * CAPTURE, posé le temps de la visite seulement.
 */
let detachInnerScroll: (() => void) | null = null;

function watchInnerScroll(client: TourClient): void {
    detachInnerScroll?.();

    let frame = 0;
    const onScroll = () => {
        // Un défilement émet des dizaines d'événements : on ne remesure
        // qu'une fois par image.
        if (frame) {
            return;
        }
        frame = requestAnimationFrame(() => {
            frame = 0;
            // `updatePositions` remet le fond et le dialogue en `display:
            // block` : l'appeler après la sortie ferait réapparaître un voile
            // sur une page redevenue normale.
            if (!client.isVisible) {
                return;
            }
            void client.updatePositions();
        });
    };

    document.addEventListener('scroll', onScroll, { capture: true, passive: true });

    detachInnerScroll = () => {
        if (frame) {
            cancelAnimationFrame(frame);
        }
        document.removeEventListener('scroll', onScroll, { capture: true });
        detachInnerScroll = null;
    };
}

/** Les textes d'une visite : clé plate et pointée → phrase. */
type TourStrings = Record<string, string>;

/**
 * Textes déjà descendus, mémorisés pour la durée de l'onglet.
 *
 * Ils pèsent ~88 Ko et ne changent qu'avec la langue : les redemander à chaque
 * lancement ferait payer deux fois le trajet qu'on vient justement d'éviter sur
 * toutes les autres pages. La clé de langue suffit à invalider le cache — c'est
 * la seule chose qui puisse changer sans rechargement complet.
 */
let cachedStrings: { locale: string; strings: TourStrings } | null = null;

/**
 * Fait descendre le contenu des visites.
 *
 * La propriété `tours` est déclarée `Inertia::optional` côté serveur : elle
 * n'accompagne aucune page, et n'arrive que sur une requête partielle qui la
 * réclame — celle-ci.
 *
 * Le point délicat est la conservation de l'état : les ancres de la visite
 * dépendent de ce que l'utilisateur a ouvert à l'écran (onglet, ligne dépliée,
 * filtre saisi), et remonter la page les emporterait. `router.reload` s'en
 * charge — son type est littéralement `Omit<VisitOptions, 'preserveScroll' |
 * 'preserveState'>`, les deux étant forcés à `true` en interne. Ne pas
 * remplacer cet appel par un `router.visit` sans les repasser explicitement.
 */
function loadTourStrings(locale: string): Promise<TourStrings> {
    if (cachedStrings && cachedStrings.locale === locale) {
        return Promise.resolve(cachedStrings.strings);
    }

    return new Promise((resolve) => {
        router.reload({
            only: ['tours'],
            onSuccess: (page) => {
                const strings = ((page.props as { tours?: TourStrings }).tours ?? {}) as TourStrings;
                cachedStrings = { locale, strings };
                resolve(strings);
            },
            // Un échec réseau ne doit pas laisser le bouton inerte sans rien
            // dire : on repart avec un jeu vide, et `startTour` s'arrête en le
            // signalant plutôt que d'ouvrir un dialogue rempli de clés brutes.
            onError: () => resolve({}),
        });
    });
}

/**
 * L'unique client de l'onglet. Mémorisé sous forme de PROMESSE pour que deux
 * clics rapprochés ne construisent pas deux instances en parallèle.
 */
let clientPromise: Promise<TourClient> | null = null;

function getTourClient(): Promise<TourClient> {
    if (!clientPromise) {
        clientPromise = import('@sjmc11/tourguidejs').then(({ TourGuideClient }) => {
            return new TourGuideClient({
                // Au-dessus des dialogues Radix (z-50) et de la barre latérale (z-10/20).
                dialogZ: 1000,
                // Valeur de départ seulement : `startTour` la remplace à CHAQUE
                // lancement par une largeur adaptée à l'écran (voir
                // `tourDialogMaxWidth`). Le client étant unique pour tout
                // l'onglet (contrainte 1), une largeur fixée ici suivrait
                // l'utilisateur d'un écran à l'autre — et 380 px débordent d'un
                // téléphone de 375 px.
                dialogMaxWidth: TOUR_DIALOG_MAX_WIDTH,
                // INDISPENSABLE, et le défaut `false` est un piège. Dans
                // `core/dialog.ts`, la correction HORIZONTALE de floating-ui est
                // écrite `shift({ crossAxis: allowDialogOverlap })` : laisser
                // l'option à `false` interdit au dialogue de revenir dans
                // l'écran quand il déborde. Sur une ancre pleine largeur — la
                // grille de cartes du tableau de bord — `autoPlacement` le pose
                // à gauche, à une abscisse négative, et le texte est coupé.
                allowDialogOverlap: true,
                // ═══════════════════════════════════════════════════════════
                // NE PAS REMETTRE `true` (le défaut de la bibliothèque)
                // ═══════════════════════════════════════════════════════════
                // Avec le défilement animé, `handleVisitStep` appelle
                // `scrollToTarget()` SANS l'attendre, puis mesure aussitôt :
                //
                //     scrollToTarget(tgInstance, nextStep.target)   // async
                //     await tgInstance.updatePositions()            // mesure
                //
                // Le surlignage et le dialogue sont donc posés sur la position
                // d'AVANT le défilement. Sur une page qui défile verticalement
                // l'erreur se corrige d'elle-même — la bibliothèque écoute le
                // `scroll` de `window` et repositionne pendant l'animation.
                //
                // Dans un tableau qui défile HORIZONTALEMENT, non : le `scroll`
                // d'un conteneur interne ne remonte pas jusqu'à `window`
                // (l'écouteur est posé sans `capture`). Le cadre restait donc
                // décalé sur les actions de fin de ligne (réclamations,
                // paiements…), et ne tombait juste que lorsque la colonne était
                // déjà visible — c'est-à-dire quand aucun défilement n'était
                // nécessaire.
                //
                // En mode instantané, `scrollIntoView` met les positions de
                // défilement à jour de façon SYNCHRONE : la mesure qui suit est
                // la bonne. Le prix est un saut au lieu d'un glissement.
                autoScrollSmooth: false,
                targetPadding: 8,
                exitOnEscape: true,
                exitOnClickOutside: false,
                showStepProgress: true,
                showStepDots: true,
                progressBar: '#0ea5e9',
                // `true` inonderait la console en production.
                debug: false,
                // Marque la visite comme vue dans le localStorage à la fin.
                completeOnFinish: true,
            }) as unknown as TourClient;
        });
    }

    return clientPromise;
}

export interface UseTourResult {
    /** La page courante déclare-t-elle une visite ? */
    hasTour: boolean;
    /** Lance la visite. Sans effet si la page n'en déclare pas. */
    startTour: () => void;
    /** Visite en cours d'affichage. */
    isRunning: boolean;
}

export function useTour(): UseTourResult {
    const page = usePage<SharedData>();
    const { t, locale } = useTranslation();

    const component = page.component;
    const auth = page.props.auth;

    const startedRef = useRef(false);
    const [isRunning, setIsRunning] = useState(false);

    const tour: AppTour | undefined = findTourForComponent(TOURS, component);

    /**
     * Construit les étapes au moment du lancement — jamais en amont. Les ancres
     * conditionnelles (onglet ouvert, liste non vide, droit accordé) ne sont
     * connues qu'à cet instant.
     */
    const buildSteps = useCallback(
        (target: AppTour, strings: TourStrings): TourStepInput[] => {
            const { steps, missing } = resolveTourSteps(target, {
                permissions: Array.isArray(auth?.permissions) ? auth.permissions : [],
                isAdmin: !!auth?.isAdmin,
                hasAnchor: (anchor) => !!document.querySelector(anchorSelector(anchor)),
            });

            // Une ancre non optionnelle introuvable = un `data-tour` renommé dans
            // le JSX sans l'être ici. Sans ce cri, la visite se viderait étape par
            // étape au fil des refontes, en silence.
            missing.forEach(({ tourId, anchor }) => {
                console.warn(`[tour] ancre introuvable : [data-tour="${anchor}"] (visite « ${tourId} »). Étape ignorée.`);
            });

            return steps.map((step) => {
                const keys = stepTranslationKeys(target.id, step);

                return {
                    // Les textes viennent du jeu chargé à la demande, pas de
                    // `translations.tours` — celui-ci ne porte plus que les
                    // libellés des boutons. Repli sur la clé brute : la même
                    // convention que `t()`, et le test de traductions les
                    // empêche d'arriver jusqu'à l'écran.
                    title: strings[keys.title] ?? keys.title,
                    content: strings[keys.content] ?? keys.content,
                    target: anchorSelector(step.anchor),
                    order: step.order,
                    fixed: step.fixed,
                    group: target.id,
                    // `beforeEnter` est attendu AVANT le rendu et le
                    // positionnement du dialogue (voir `handleVisitStep`) : le
                    // menu est donc déjà déployé quand TourGuide mesure la
                    // place disponible.
                    beforeEnter: step.openOnEnter
                        ? async () => {
                              await setAnchorOpen(step.anchor, true);
                              openedAnchors.add(step.anchor);
                          }
                        : undefined,
                    beforeLeave: step.openOnEnter
                        ? async () => {
                              openedAnchors.delete(step.anchor);
                              await setAnchorOpen(step.anchor, false);
                          }
                        : undefined,
                };
            });
        },
        [auth],
    );

    const startTour = useCallback(async () => {
        if (!tour) {
            return;
        }

        // Les textes descendent AVANT la construction des étapes — et donc
        // avant la lecture du DOM. L'ordre compte : `buildSteps` interroge les
        // ancres présentes à l'écran, et la requête partielle qui précède peut
        // remonter la page.
        const strings = await loadTourStrings(locale);

        if (Object.keys(strings).length === 0) {
            console.warn('[tour] contenu des visites indisponible (requête partielle « tours » en échec) — visite annulée.');
            return;
        }

        const steps = buildSteps(tour, strings);

        if (steps.length === 0) {
            console.warn(`[tour] visite « ${tour.id} » sans aucune étape jouable — rien à afficher.`);
            return;
        }

        const client = await getTourClient();

        // `start()` rejette avec « Tour already active » si une visite tourne
        // déjà — un double clic ne doit pas produire une erreur en console.
        if (client.isVisible) {
            return;
        }

        // Les étapes ET les libellés sont réinjectés à CHAQUE lancement : ils
        // dépendent de la page, des droits et de la langue, qui ont pu changer
        // depuis la construction du client. `start()` relit `options.steps`
        // (voir `handleTourStart` → `computeTourSteps`).
        //
        // La largeur du dialogue aussi : elle dépend de l'écran, qui a pu
        // changer (fenêtre redimensionnée, téléphone tourné). `setOptions`
        // repasse par `renderDialogHtml`, qui la réapplique au dialogue.
        // Tourner le téléphone PENDANT une visite garde la largeur du
        // lancement : plus étroite, elle tient ; plus large, elle se corrige au
        // lancement suivant.
        //
        // Le tout passe par `tourLaunchOptions` (registry.ts), testée : c'est
        // elle qui garantit que la largeur accompagne chaque lancement.
        await client.setOptions(
            tourLaunchOptions({
                steps,
                labels: {
                    next: t('tours', 'ui.next', 'Suivant'),
                    prev: t('tours', 'ui.prev', 'Retour'),
                    finish: t('tours', 'ui.finish', 'Terminer'),
                },
                viewportWidth: viewportWidth(),
            }),
        );

        // Réaffectation, pas empilement : la bibliothèque stocke un seul
        // callback global par événement.
        //
        // `beforeLeave` d'une étape ne se déclenche que sur un CHANGEMENT
        // d'étape. Sortir par la croix, par Échap ou par « Terminer » ne passe
        // pas par là — sans ce nettoyage, le menu ouvert par la dernière étape
        // resterait déployé sur une page redevenue normale.
        client.onFinish(() => {
            setIsRunning(false);
            detachInnerScroll?.();
            void closeAllOpenedAnchors();
        });
        client.onAfterExit(() => {
            setIsRunning(false);
            detachInnerScroll?.();
            void closeAllOpenedAnchors();
        });

        await client.start(tour.id);
        watchInnerScroll(client);
        setIsRunning(true);
    }, [tour, buildSteps, t, locale]);

    /**
     * Démarrage automatique — uniquement pour une visite qui le demande, et une
     * seule fois par navigateur.
     *
     * Le double `requestAnimationFrame` laisse React peindre : sans lui, les
     * ancres existent dans le DOM mais mesurent 0×0 et le dialogue se colle en
     * haut à gauche.
     */
    useEffect(() => {
        startedRef.current = false;

        if (!tour?.autoStart) {
            return;
        }

        // Le localStorage peut lever (navigation privée, cookies bloqués) :
        // l'échec doit valoir « jamais vue », pas planter la page.
        let alreadySeen = false;
        try {
            alreadySeen = isTourCompleted(window.localStorage.getItem(TOUR_STORAGE_KEY), tour.id);
        } catch {
            alreadySeen = false;
        }

        if (alreadySeen) {
            return;
        }

        let cancelled = false;

        const raf = requestAnimationFrame(() => {
            requestAnimationFrame(() => {
                if (cancelled || startedRef.current) {
                    return;
                }

                startedRef.current = true;
                void startTour();
            });
        });

        return () => {
            cancelled = true;
            cancelAnimationFrame(raf);
        };
        // `startTour` change avec la locale et les droits : on ne veut relancer
        // la sonde que quand la PAGE change.
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [tour?.id, component]);

    /**
     * Une navigation Inertia démonte la page mais pas forcément le layout : sans
     * ce nettoyage, le backdrop de la visite survivrait à l'écran qu'il
     * surlignait et bloquerait la page suivante.
     *
     * On ferme la visite ; on ne détruit PAS le client (contrainte 1).
     */
    useEffect(() => {
        return () => {
            // On interroge `clientPromise` directement, sans passer par
            // `getTourClient()` : celui-ci CONSTRUIRAIT le client. Le paquet
            // serait alors chargé à chaque navigation, y compris sur les écrans
            // où personne n'a jamais ouvert de visite.
            if (!clientPromise) {
                return;
            }

            void clientPromise.then((client) => {
                if (client.isVisible) {
                    void client.exit();
                }
            });
        };
    }, [component, locale]);

    return {
        hasTour: !!tour,
        startTour: () => void startTour(),
        isRunning,
    };
}
