import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
    TOUR_DIALOG_MAX_WIDTH,
    TOUR_DIALOG_MIN_WIDTH,
    TOUR_DIALOG_SCREEN_MARGIN,
    anchorSelector,
    findTourForComponent,
    isTourCompleted,
    needsToggle,
    resolveTourSteps,
    stepTranslationKeys,
    tourDialogMaxWidth,
    tourLaunchOptions,
} from './registry';
import { TOURS } from './tours';
import type { AppTour, TourViewerContext } from './types';

/** Contexte de lecture par défaut : tout est présent, aucun droit particulier. */
function viewer(overrides: Partial<TourViewerContext> = {}): TourViewerContext {
    return {
        permissions: [],
        isAdmin: false,
        hasAnchor: () => true,
        ...overrides,
    };
}

const tour: AppTour = {
    id: 'demo',
    components: ['demo/index'],
    steps: [
        { anchor: 'c', key: 'c', order: 30 },
        { anchor: 'a', key: 'a', order: 10 },
        { anchor: 'b', key: 'b', order: 20 },
    ],
};

describe('findTourForComponent', () => {
    it('retrouve la visite par nom de composant Inertia', () => {
        expect(findTourForComponent([tour], 'demo/index')?.id).toBe('demo');
    });

    it('ne rend rien pour une page sans visite déclarée', () => {
        expect(findTourForComponent([tour], 'autre/index')).toBeUndefined();
    });

    it('rattache une visite à plusieurs composants', () => {
        const multi: AppTour = { ...tour, components: ['a/index', 'b/index'] };
        expect(findTourForComponent([multi], 'b/index')?.id).toBe('demo');
    });
});

describe('resolveTourSteps — ordre', () => {
    it("trie par `order` croissant, quel que soit l'ordre de déclaration", () => {
        const { steps } = resolveTourSteps(tour, viewer());
        expect(steps.map((s) => s.key)).toEqual(['a', 'b', 'c']);
    });
});

describe('resolveTourSteps — droits', () => {
    const gated: AppTour = {
        id: 'demo',
        components: ['demo/index'],
        steps: [
            { anchor: 'public', key: 'public', order: 10 },
            { anchor: 'admin', key: 'admin', order: 20, adminOnly: true },
            { anchor: 'perm', key: 'perm', order: 30, permission: 'reservation.create' },
        ],
    };

    it('cache une étape `adminOnly` à un non-administrateur', () => {
        const { steps } = resolveTourSteps(gated, viewer());
        expect(steps.map((s) => s.key)).not.toContain('admin');
    });

    it('montre une étape `adminOnly` à un administrateur', () => {
        const { steps } = resolveTourSteps(gated, viewer({ isAdmin: true }));
        expect(steps.map((s) => s.key)).toContain('admin');
    });

    it('cache une étape dont la permission manque', () => {
        const { steps } = resolveTourSteps(gated, viewer());
        expect(steps.map((s) => s.key)).not.toContain('perm');
    });

    it('montre une étape dont la permission est accordée', () => {
        const { steps } = resolveTourSteps(gated, viewer({ permissions: ['reservation.create'] }));
        expect(steps.map((s) => s.key)).toContain('perm');
    });

    it('un administrateur voit une étape à permission sans porter la permission nominativement', () => {
        const { steps } = resolveTourSteps(gated, viewer({ isAdmin: true }));
        expect(steps.map((s) => s.key)).toContain('perm');
    });
});

describe('resolveTourSteps — ancres absentes', () => {
    const withOptional: AppTour = {
        id: 'demo',
        components: ['demo/index'],
        steps: [
            { anchor: 'present', key: 'present', order: 10 },
            { anchor: 'absent', key: 'absent', order: 20 },
            { anchor: 'absent-ok', key: 'absentOk', order: 30, optional: true },
        ],
    };

    const partial = viewer({ hasAnchor: (a) => a === 'present' });

    it("retire l'étape dont l'ancre n'est pas dans le DOM, au lieu de casser la visite", () => {
        const { steps } = resolveTourSteps(withOptional, partial);
        expect(steps.map((s) => s.key)).toEqual(['present']);
    });

    it("signale une ancre NON optionnelle manquante — c'est presque toujours un renommage oublié", () => {
        const { missing } = resolveTourSteps(withOptional, partial);
        expect(missing).toEqual([{ tourId: 'demo', anchor: 'absent' }]);
    });

    it("ne signale PAS une ancre optionnelle manquante — c'est un état normal", () => {
        const { missing } = resolveTourSteps(withOptional, partial);
        expect(missing.map((m) => m.anchor)).not.toContain('absent-ok');
    });

    it('rend une visite vide plutôt que de lever, si plus aucune ancre ne répond', () => {
        const { steps } = resolveTourSteps(withOptional, viewer({ hasAnchor: () => false }));
        expect(steps).toEqual([]);
    });
});

describe('clés et sélecteurs', () => {
    it("compose les clés de traduction à partir de la visite et de l'étape", () => {
        expect(stepTranslationKeys('dashboard', { anchor: 'x', key: 'welcome', order: 1 })).toEqual({
            title: 'dashboard.welcome.title',
            content: 'dashboard.welcome.content',
        });
    });

    it("compose le sélecteur CSS de l'ancre", () => {
        expect(anchorSelector('dashboard-tabs')).toBe('[data-tour="dashboard-tabs"]');
    });
});

/*
 * Bascule d'un déclencheur Radix. Un menu s'ouvre et se ferme par le MÊME
 * clic : se tromper de sens referme exactement ce qu'on voulait montrer.
 */
describe('needsToggle', () => {
    it('clique pour ouvrir un menu fermé', () => {
        expect(needsToggle('closed', true)).toBe(true);
    });

    it('ne reclique pas un menu déjà ouvert — ce serait le refermer', () => {
        expect(needsToggle('open', true)).toBe(false);
    });

    it('clique pour refermer un menu ouvert', () => {
        expect(needsToggle('open', false)).toBe(true);
    });

    it('ne touche pas à un menu déjà fermé', () => {
        expect(needsToggle('closed', false)).toBe(false);
    });

    it("traite l'attribut absent comme « fermé » — cas d'un menu jamais ouvert", () => {
        expect(needsToggle(null, true)).toBe(true);
        expect(needsToggle(undefined, false)).toBe(false);
    });
});

/*
 * Lecture du localStorage de TourGuide. On relit son format (liste de groupes
 * séparés par des virgules, écrite par `handleFinishTour.ts`) au lieu
 * d'instancier un client pour appeler `isFinished` — construire un client
 * jetable ajouterait un second dialogue au DOM et casserait l'affichage.
 */
describe('isTourCompleted', () => {
    it("rend faux quand rien n'a jamais été enregistré", () => {
        expect(isTourCompleted(null, 'dashboard')).toBe(false);
        expect(isTourCompleted(undefined, 'dashboard')).toBe(false);
        expect(isTourCompleted('', 'dashboard')).toBe(false);
    });

    it('reconnaît une visite unique', () => {
        expect(isTourCompleted('dashboard', 'dashboard')).toBe(true);
    });

    it('reconnaît une visite au milieu de la liste', () => {
        expect(isTourCompleted('reservations,dashboard,depenses', 'dashboard')).toBe(true);
    });

    it('tolère les espaces autour des virgules', () => {
        expect(isTourCompleted('reservations, dashboard , depenses', 'dashboard')).toBe(true);
    });

    it("ne confond pas deux identifiants dont l'un est le préfixe de l'autre", () => {
        expect(isTourCompleted('dashboard-menage', 'dashboard')).toBe(false);
    });

    it('rend faux pour une visite absente de la liste', () => {
        expect(isTourCompleted('reservations,depenses', 'dashboard')).toBe(false);
    });
});

/*
 * Garde-fous sur le registre RÉEL. Ils ne testent pas une logique, ils
 * protègent des collisions qu'aucune relecture ne rattrape à l'œil : deux
 * visites qui partagent un identifiant se marcheraient dessus dans le
 * localStorage, et deux `order` identiques rendraient l'ordre des étapes
 * dépendant de l'ordre de déclaration.
 */
describe('registre réel', () => {
    it('chaque visite porte un identifiant unique', () => {
        const ids = TOURS.map((t) => t.id);
        expect(new Set(ids).size).toBe(ids.length);
    });

    it("un composant Inertia n'est réclamé que par une seule visite", () => {
        const components = TOURS.flatMap((t) => t.components);
        expect(new Set(components).size).toBe(components.length);
    });

    it("les `order` sont uniques à l'intérieur de chaque visite", () => {
        for (const t of TOURS) {
            const orders = t.steps.map((s) => s.order);
            expect(new Set(orders).size, `visite « ${t.id} »`).toBe(orders.length);
        }
    });

    it("les ancres sont uniques à l'intérieur de chaque visite", () => {
        for (const t of TOURS) {
            const anchors = t.steps.map((s) => s.anchor);
            expect(new Set(anchors).size, `visite « ${t.id} »`).toBe(anchors.length);
        }
    });

    it('chaque visite déclare au moins une étape', () => {
        for (const t of TOURS) {
            expect(t.steps.length, `visite « ${t.id} »`).toBeGreaterThan(0);
        }
    });
});

/*
 * La largeur du dialogue suit l'écran, lue à chaque lancement (use-tour.tsx).
 * Le défaut à ne pas reproduire : 380 px fixes sur un écran de 375 px — dialogue
 * posé à x = −5, texte coupé.
 */
describe('tourDialogMaxWidth', () => {
    it('garde 380 px sur un écran large', () => {
        expect(tourDialogMaxWidth(1440)).toBe(TOUR_DIALOG_MAX_WIDTH);
        expect(tourDialogMaxWidth(768)).toBe(380);
        expect(tourDialogMaxWidth(410)).toBe(380);
    });

    it('se resserre sur un téléphone : l’écran moins les deux marges de floating-ui', () => {
        expect(TOUR_DIALOG_SCREEN_MARGIN).toBe(15);
        expect(tourDialogMaxWidth(375)).toBe(345);
        expect(tourDialogMaxWidth(390)).toBe(360);
        expect(tourDialogMaxWidth(320)).toBe(290);
        expect(tourDialogMaxWidth(375.6)).toBe(345);
    });

    it('tient toujours entre les deux marges, de 320 à 1 920 px', () => {
        for (let width = 320; width <= 1920; width++) {
            const dialog = tourDialogMaxWidth(width);
            expect(dialog + 2 * TOUR_DIALOG_SCREEN_MARGIN, `écran de ${width} px`).toBeLessThanOrEqual(width);
            expect(dialog).toBeLessThanOrEqual(TOUR_DIALOG_MAX_WIDTH);
        }
    });

    it('ne rend jamais 0, négatif ni NaN — la bibliothèque garderait alors la largeur précédente', () => {
        for (const width of [0, -10, Number.NaN, Number.POSITIVE_INFINITY]) {
            expect(tourDialogMaxWidth(width), String(width)).toBe(TOUR_DIALOG_MAX_WIDTH);
        }
        expect(tourDialogMaxWidth(100)).toBe(TOUR_DIALOG_MIN_WIDTH);
    });
});

/*
 * Le BRANCHEMENT de la largeur : `tourDialogMaxWidth` seule ne suffit pas, il
 * faut qu'elle parte à chaque lancement. Retirer la largeur de l'appel à
 * `setOptions` ramenait le défaut tel quel (380 px sur un écran de 375,
 * dialogue à x = −5) sans qu'aucun test ne tombe : le hook n'est pas monté en
 * test (pas de DOM). D'où une fonction pure pour les options, et un contrôle
 * du source pour son appel — comme yield-tour.test.ts le fait pour les ancres.
 */
describe('tourLaunchOptions', () => {
    const labels = { next: 'Suivant', prev: 'Retour', finish: 'Terminer' };

    it('porte la largeur adaptée à l’écran, à chaque lancement', () => {
        expect(tourLaunchOptions({ steps: [], labels, viewportWidth: 375 }).dialogMaxWidth).toBe(345);
        expect(tourLaunchOptions({ steps: [], labels, viewportWidth: 1440 }).dialogMaxWidth).toBe(TOUR_DIALOG_MAX_WIDTH);
        // Largeur illisible : jamais de valeur que la bibliothèque ignorerait.
        expect(tourLaunchOptions({ steps: [], labels, viewportWidth: Number.NaN }).dialogMaxWidth).toBe(TOUR_DIALOG_MAX_WIDTH);
    });

    it('transmet les étapes et les libellés des boutons sous les noms de la bibliothèque', () => {
        const steps = [{ title: 'A' }, { title: 'B' }];
        expect(tourLaunchOptions({ steps, labels, viewportWidth: 390 })).toEqual({
            steps,
            nextLabel: 'Suivant',
            prevLabel: 'Retour',
            finishLabel: 'Terminer',
            dialogMaxWidth: 360,
        });
    });
});

describe('use-tour.tsx : la largeur part avec chaque lancement', () => {
    const source = readFileSync(resolve(__dirname, 'use-tour.tsx'), 'utf8');
    const startTour = source.slice(source.indexOf('const startTour = useCallback('), source.indexOf('}, [tour, buildSteps, t, locale]);'));

    it('startTour passe par tourLaunchOptions, avec la largeur de l’écran lue au lancement', () => {
        expect(startTour.length).toBeGreaterThan(0);
        expect(startTour).toMatch(/client\.setOptions\(\s*tourLaunchOptions\(\{/);
        expect(startTour).toMatch(/viewportWidth: viewportWidth\(\)/);
        // Un seul setOptions dans le lancement : un second pourrait écraser la largeur.
        expect(startTour.match(/setOptions\(/g)).toHaveLength(1);
    });

    it('le constructeur du client unique ne fige aucune largeur en dur', () => {
        expect(source).not.toMatch(/dialogMaxWidth:\s*\d/);
        expect(source).toMatch(/dialogMaxWidth: TOUR_DIALOG_MAX_WIDTH/);
    });
});
