import { describe, expect, it, vi } from 'vitest';
import {
    HIDE_ORDER,
    LISTING_MIN_WIDTH,
    NAME_DETAIL_MIN_WIDTH,
    PINNED_CELL_CLASS,
    PINNED_COLUMNS,
    PINNED_FULL_WIDTH,
    PINNED_MIN_WIDTH,
    PINNED_RESIZE_STEP,
    PINNED_WIDTHS,
    PINNED_WIDTH_STORAGE_KEY,
    ambiguousListingUids,
    applyPinnedLayout,
    clampPinnedWidth,
    computePinnedLayout,
    createPinnedResizeGesture,
    createPinnedWidthController,
    listingIdentity,
    listingNames,
    parseStoredPinnedWidth,
    pinnedCssVariables,
    pinnedDataAttributes,
    pinnedTourAnchors,
    pinnedWidthForKey,
    readStoredPinnedWidth,
    setTextSelectionLocked,
    writeStoredPinnedWidth,
    type PinnedColumn,
    type PinnedLayout,
    type PinnedWidthStorage,
} from './pinned-layout';

/** Somme des largeurs RENDUES : la colonne logement compte pour sa largeur élastique. */
function renderedWidth(layout: PinnedLayout): number {
    return PINNED_COLUMNS.filter((col) => layout.visible[col]).reduce(
        (total, col) => total + (col === 'listing' ? layout.listingWidth : PINNED_WIDTHS[col]),
        0,
    );
}

/** Toutes les largeurs entières de la plage, du plus large au plus serré. */
const EVERY_WIDTH = Array.from({ length: PINNED_FULL_WIDTH - PINNED_MIN_WIDTH + 1 }, (_, i) => PINNED_FULL_WIDTH - i);

describe('bornes de la zone figée', () => {
    it("la largeur complète vaut 902 px — la mise en page d'avant la barre, colonne yield élargie à ses trois boutons", () => {
        expect(PINNED_FULL_WIDTH).toBe(902);
    });

    it('la colonne yield tient ENTIERS ses trois contrôles : interrupteur, envoi, retour aux prix d’avant', () => {
        // px-2 (2 × 8) + Switch w-9 (36) + deux boutons w-8 (2 × 32) + deux gap-1.5 (2 × 6) :
        // en dessous, le bouton de retour déborde sous la colonne suivante ou sous la barre.
        expect(PINNED_WIDTHS.yield).toBeGreaterThanOrEqual(2 * 8 + 36 + 2 * 32 + 2 * 6);
    });

    it('la largeur minimale est celle du logement seul', () => {
        expect(PINNED_MIN_WIDTH).toBe(LISTING_MIN_WIDTH);
        expect(PINNED_MIN_WIDTH).toBe(160);
    });

    it("borne une largeur hors plage, et retombe sur la largeur complète si elle n'est pas un nombre", () => {
        expect(clampPinnedWidth(10)).toBe(PINNED_MIN_WIDTH);
        expect(clampPinnedWidth(5000)).toBe(PINNED_FULL_WIDTH);
        expect(clampPinnedWidth(Number.NaN)).toBe(PINNED_FULL_WIDTH);
        expect(clampPinnedWidth(Number.POSITIVE_INFINITY)).toBe(PINNED_FULL_WIDTH);
        expect(clampPinnedWidth(500.4)).toBe(500);
    });
});

describe('computePinnedLayout — pleine largeur', () => {
    const full = computePinnedLayout(PINNED_FULL_WIDTH);

    it('affiche les huit colonnes, logement à exactement 230 px', () => {
        expect(full.hidden).toEqual([]);
        expect(full.visibleCount).toBe(8);
        expect(full.listingWidth).toBe(230);
    });

    it("reproduit exactement les décalages de l'ancienne mise en page figée", () => {
        expect(full.left).toEqual({ select: 0, listing: 44, yield: 274, min: 402, base: 486, max: 570, occupancy: 654, group: 764 });
    });

    it("a la place d'afficher le nom complet sous le nom interne", () => {
        expect(full.showNameDetail).toBe(true);
    });
});

describe('computePinnedLayout — au plus serré', () => {
    const min = computePinnedLayout(PINNED_MIN_WIDTH);

    it('ne garde que la colonne logement, collée au bord', () => {
        expect(PINNED_COLUMNS.filter((col) => min.visible[col])).toEqual(['listing']);
        expect(min.visibleCount).toBe(1);
        expect(min.listingWidth).toBe(LISTING_MIN_WIDTH);
        expect(min.left.listing).toBe(0);
    });

    it("masque tout le reste, dans l'ordre de disparition", () => {
        expect(min.hidden).toEqual([...HIDE_ORDER]);
    });

    it("n'a plus la place du nom complet", () => {
        expect(min.showNameDetail).toBe(false);
    });

    it('une largeur demandée sous le minimum donne la même disposition', () => {
        expect(computePinnedLayout(0)).toEqual(min);
        expect(computePinnedLayout(-200)).toEqual(min);
    });
});

describe('computePinnedLayout — masquage progressif', () => {
    it("les colonnes disparaissent dans l'ordre groupe → occupation → max → base → min → yield → sélection", () => {
        const disappearance: string[] = [];
        let previous = computePinnedLayout(PINNED_FULL_WIDTH);

        for (const width of EVERY_WIDTH) {
            const layout = computePinnedLayout(width);
            for (const col of HIDE_ORDER) {
                if (previous.visible[col] && !layout.visible[col]) {
                    disappearance.push(col);
                }
            }
            previous = layout;
        }

        expect(disappearance).toEqual(['group', 'occupancy', 'max', 'base', 'min', 'yield', 'select']);
    });

    it('à chaque largeur, une colonne affichée implique que toutes celles qui partent APRÈS elle le sont aussi', () => {
        for (const width of EVERY_WIDTH) {
            const layout = computePinnedLayout(width);
            HIDE_ORDER.forEach((col, i) => {
                if (layout.visible[col]) {
                    for (const later of HIDE_ORDER.slice(i + 1)) {
                        expect(layout.visible[later], `largeur ${width} : ${col} affichée mais ${later} masquée`).toBe(true);
                    }
                }
            });
        }
    });

    it("une colonne n'est jamais écrasée : la somme rendue vaut exactement la largeur, le logement absorbe le reste", () => {
        for (const width of EVERY_WIDTH) {
            const layout = computePinnedLayout(width);
            expect(renderedWidth(layout), `largeur ${width}`).toBe(width);
            expect(layout.listingWidth).toBeGreaterThanOrEqual(LISTING_MIN_WIDTH);
        }
    });

    it('les décalages `left` empilent les colonnes rendues sans chevauchement ni trou', () => {
        for (const width of EVERY_WIDTH) {
            const layout = computePinnedLayout(width);
            let expected = 0;
            for (const col of PINNED_COLUMNS) {
                if (!layout.visible[col]) continue;
                expect(layout.left[col], `largeur ${width}, colonne ${col}`).toBe(expected);
                expected += col === 'listing' ? layout.listingWidth : PINNED_WIDTHS[col];
            }
            expect(expected).toBe(width);
        }
    });

    it('visibleCount compte les colonnes rendues (pour le colSpan de la ligne vide)', () => {
        for (const width of EVERY_WIDTH) {
            const layout = computePinnedLayout(width);
            expect(layout.visibleCount).toBe(PINNED_COLUMNS.filter((col) => layout.visible[col]).length);
        }
    });

    it('bascule au pixel près : le groupe part sous 832 px, le logement reprend alors sa place', () => {
        const withGroup = computePinnedLayout(832);
        expect(withGroup.visible.group).toBe(true);
        expect(withGroup.listingWidth).toBe(LISTING_MIN_WIDTH);

        const withoutGroup = computePinnedLayout(831);
        expect(withoutGroup.visible.group).toBe(false);
        expect(withoutGroup.visible.occupancy).toBe(true);
        expect(withoutGroup.listingWidth).toBe(831 - (44 + 128 + 84 + 84 + 84 + 110));
    });

    it('la colonne yield part sous 332 px (sélection + logement minimal + yield)', () => {
        expect(computePinnedLayout(332).visible.yield).toBe(true);
        expect(computePinnedLayout(332).visible.min).toBe(false);
        expect(computePinnedLayout(331).visible.yield).toBe(false);
    });

    it('la case de sélection part la dernière, à 204 px', () => {
        const withSelect = computePinnedLayout(204);
        expect(PINNED_COLUMNS.filter((col) => withSelect.visible[col])).toEqual(['select', 'listing']);
        expect(withSelect.left.listing).toBe(PINNED_WIDTHS.select);

        const alone = computePinnedLayout(203);
        expect(PINNED_COLUMNS.filter((col) => alone.visible[col])).toEqual(['listing']);
        expect(alone.listingWidth).toBe(203);
    });

    it("le nom complet ne s'affiche que si la colonne logement fait au moins NAME_DETAIL_MIN_WIDTH", () => {
        for (const width of EVERY_WIDTH) {
            const layout = computePinnedLayout(width);
            expect(layout.showNameDetail).toBe(layout.listingWidth >= NAME_DETAIL_MIN_WIDTH);
        }
    });
});

describe('variables CSS et attributs data-*', () => {
    it('pose la largeur totale, les décalages et les largeurs de chaque colonne', () => {
        const vars = pinnedCssVariables(computePinnedLayout(PINNED_FULL_WIDTH));
        expect(vars['--yt-pinned-w']).toBe('902px');
        expect(vars['--yt-w-listing']).toBe('230px');
        expect(vars['--yt-w-select']).toBe('44px');
        expect(vars['--yt-left-group']).toBe('764px');
        for (const col of PINNED_COLUMNS) {
            expect(vars[`--yt-left-${col}`]).toMatch(/^\d+px$/);
            expect(vars[`--yt-w-${col}`]).toMatch(/^\d+px$/);
        }
    });

    it('la largeur élastique du logement passe dans --yt-w-listing', () => {
        expect(pinnedCssVariables(computePinnedLayout(831))['--yt-w-listing']).toBe('297px');
    });

    it('pleine largeur : aucun data-hide-*, et data-name-detail présent', () => {
        const attrs = pinnedDataAttributes(computePinnedLayout(PINNED_FULL_WIDTH));
        for (const col of HIDE_ORDER) {
            expect(attrs[`data-hide-${col}`]).toBeUndefined();
        }
        expect(attrs['data-name-detail']).toBe('');
    });

    it('au plus serré : un data-hide-* par colonne masquable, jamais pour le logement', () => {
        const attrs = pinnedDataAttributes(computePinnedLayout(PINNED_MIN_WIDTH));
        for (const col of HIDE_ORDER) {
            expect(attrs[`data-hide-${col}`]).toBe('');
        }
        expect('data-hide-listing' in attrs).toBe(false);
        expect(attrs['data-name-detail']).toBeUndefined();
    });

    it('applyPinnedLayout écrit toutes les variables et bascule chaque attribut sur un seul élément', () => {
        const setProperty = vi.fn();
        const toggleAttribute = vi.fn(() => true);
        const layout = computePinnedLayout(700);

        applyPinnedLayout({ style: { setProperty }, toggleAttribute }, layout);

        const vars = pinnedCssVariables(layout);
        expect(setProperty).toHaveBeenCalledTimes(Object.keys(vars).length);
        for (const [name, value] of Object.entries(vars)) {
            expect(setProperty).toHaveBeenCalledWith(name, value);
        }
        for (const col of HIDE_ORDER) {
            expect(toggleAttribute).toHaveBeenCalledWith(`data-hide-${col}`, !layout.visible[col]);
        }
        expect(toggleAttribute).toHaveBeenCalledWith('data-name-detail', layout.showNameDetail);
    });
});

/*
 * Les classes de géométrie sont écrites à la main (Tailwind ne lit que des
 * classes littérales). Une coquille — `--yt-left-min` sur la colonne base —
 * poserait une colonne sous sa voisine sans qu'aucun type ne proteste.
 */
describe('PINNED_CELL_CLASS', () => {
    it.each(PINNED_COLUMNS.map((col) => [col]))('la colonne %s ne lit que SES variables', (col: PinnedColumn) => {
        const cls = PINNED_CELL_CLASS[col];
        // Classes attendues assemblées en morceaux, jamais écrites d'un bloc
        // avec une interpolation : Tailwind lit aussi ce fichier (voir le
        // commentaire de PINNED_CELL_CLASS).
        const leftVar = 'var(--yt-left-' + col + ')';
        const widthVar = 'var(--yt-w-' + col + ')';
        expect(cls).toContain('left-[' + leftVar + ']');
        expect(cls).toContain(' w-[' + widthVar + ']');
        expect(cls).toContain('min-w-[' + widthVar + ']');
        expect(cls).toContain('max-w-[' + widthVar + ']');

        const others = PINNED_COLUMNS.filter((other) => other !== col);
        for (const other of others) {
            expect(cls).not.toContain(`--yt-left-${other})`);
            expect(cls).not.toContain(`--yt-w-${other})`);
        }
    });

    it('chaque colonne masquable se retire sur SON attribut data-hide-*', () => {
        for (const col of HIDE_ORDER) {
            // Chaîne assemblée en morceaux : écrite d'un bloc avec une
            // interpolation, Tailwind la lirait comme une classe et produirait
            // un sélecteur invalide (voir le commentaire de PINNED_CELL_CLASS).
            const hideClass = ['group-data-[hide-', col, ']/yt:hidden'].join('');
            expect(PINNED_CELL_CLASS[col]).toContain(hideClass);
            expect(PINNED_CELL_CLASS[col].match(/group-data-\[hide-/g)).toHaveLength(1);
        }
    });

    it('la colonne logement ne disparaît jamais', () => {
        expect(PINNED_CELL_CLASS.listing).not.toContain('hidden');
    });
});

describe('pinnedWidthForKey — clavier', () => {
    it("les flèches déplacent la barre d'un pas", () => {
        expect(pinnedWidthForKey(500, 'ArrowLeft')).toBe(500 - PINNED_RESIZE_STEP);
        expect(pinnedWidthForKey(500, 'ArrowRight')).toBe(500 + PINNED_RESIZE_STEP);
        expect(PINNED_RESIZE_STEP).toBe(16);
    });

    it('les flèches restent dans les bornes', () => {
        expect(pinnedWidthForKey(PINNED_MIN_WIDTH + 3, 'ArrowLeft')).toBe(PINNED_MIN_WIDTH);
        expect(pinnedWidthForKey(PINNED_FULL_WIDTH - 3, 'ArrowRight')).toBe(PINNED_FULL_WIDTH);
    });

    it('Début = largeur minimale, Fin = largeur complète', () => {
        expect(pinnedWidthForKey(500, 'Home')).toBe(PINNED_MIN_WIDTH);
        expect(pinnedWidthForKey(500, 'End')).toBe(PINNED_FULL_WIDTH);
    });

    it('ignore les autres touches (Tab doit continuer à déplacer le focus)', () => {
        expect(pinnedWidthForKey(500, 'Tab')).toBeNull();
        expect(pinnedWidthForKey(500, 'Enter')).toBeNull();
        expect(pinnedWidthForKey(500, 'ArrowUp')).toBeNull();
    });
});

describe('valeur mémorisée', () => {
    it('accepte un entier dans les bornes, bornes comprises', () => {
        expect(parseStoredPinnedWidth('500')).toBe(500);
        expect(parseStoredPinnedWidth(' 500 ')).toBe(500);
        expect(parseStoredPinnedWidth('160')).toBe(160);
        expect(parseStoredPinnedWidth('902')).toBe(902);
    });

    it.each([
        ['absente', null],
        ['non définie', undefined],
        ['vide', ''],
        ['texte', 'large'],
        ['sous le minimum', '159'],
        ['au-dessus du maximum', '903'],
        ['décimale', '500.5'],
        ['négative', '-300'],
        ['notation scientifique', '1e3'],
        ['JSON', '{"width":500}'],
        ['nombre brut (pas une chaîne)', 500],
    ])('valeur %s → largeur complète', (_label, raw) => {
        expect(parseStoredPinnedWidth(raw)).toBe(PINNED_FULL_WIDTH);
    });

    function memoryStorage(initial: Record<string, string> = {}): PinnedWidthStorage & { data: Record<string, string> } {
        const data = { ...initial };
        return {
            data,
            getItem: (key) => (key in data ? data[key] : null),
            setItem: (key, value) => {
                data[key] = value;
            },
        };
    }

    it('lit la clé yield.table.pinnedWidth', () => {
        expect(PINNED_WIDTH_STORAGE_KEY).toBe('yield.table.pinnedWidth');
        const storage = memoryStorage({ [PINNED_WIDTH_STORAGE_KEY]: '420' });
        expect(readStoredPinnedWidth(() => storage)).toBe(420);
    });

    it('rien de mémorisé → largeur complète', () => {
        expect(readStoredPinnedWidth(() => memoryStorage())).toBe(PINNED_FULL_WIDTH);
        expect(readStoredPinnedWidth(() => null)).toBe(PINNED_FULL_WIDTH);
    });

    it("l'accès au stockage qui lève (stockage bloqué) ne casse rien", () => {
        const blocked = () => {
            throw new DOMException('The operation is insecure.', 'SecurityError');
        };
        expect(readStoredPinnedWidth(blocked)).toBe(PINNED_FULL_WIDTH);
        expect(writeStoredPinnedWidth(blocked, 500)).toBe(false);
    });

    it('getItem / setItem qui lèvent (navigation privée, quota) ne cassent rien', () => {
        const broken: PinnedWidthStorage = {
            getItem: () => {
                throw new Error('private mode');
            },
            setItem: () => {
                throw new Error('QuotaExceededError');
            },
        };
        expect(readStoredPinnedWidth(() => broken)).toBe(PINNED_FULL_WIDTH);
        expect(writeStoredPinnedWidth(() => broken, 500)).toBe(false);
    });

    it('écrit la largeur bornée, relisible telle quelle', () => {
        const storage = memoryStorage();
        expect(writeStoredPinnedWidth(() => storage, 431)).toBe(true);
        expect(storage.data[PINNED_WIDTH_STORAGE_KEY]).toBe('431');
        expect(readStoredPinnedWidth(() => storage)).toBe(431);

        writeStoredPinnedWidth(() => storage, 99999);
        expect(storage.data[PINNED_WIDTH_STORAGE_KEY]).toBe(String(PINNED_FULL_WIDTH));
    });
});

describe("listingNames — nom interne d'abord", () => {
    it('nom interne en premier, nom complet en seconde ligne', () => {
        expect(listingNames({ uid: '101', name: 'Villa des Artistes — Hammamet Nord', internal_name: 'Artistes' })).toEqual({
            primary: 'Artistes',
            secondary: 'Villa des Artistes — Hammamet Nord',
            disambiguation: null,
        });
    });

    it('sans nom interne, le nom complet seul — pas de seconde ligne', () => {
        expect(listingNames({ uid: '101', name: 'Dar Yasmine', internal_name: null })).toEqual({
            primary: 'Dar Yasmine',
            secondary: null,
            disambiguation: null,
        });
    });

    it('un nom interne vide ou blanc compte comme absent', () => {
        expect(listingNames({ uid: '101', name: 'Dar Yasmine', internal_name: '' }).secondary).toBeNull();
        expect(listingNames({ uid: '101', name: 'Dar Yasmine', internal_name: '   ' })).toEqual({
            primary: 'Dar Yasmine',
            secondary: null,
            disambiguation: null,
        });
    });

    it('un nom interne identique au nom complet ne se répète pas', () => {
        expect(listingNames({ uid: '101', name: 'Dar Yasmine', internal_name: 'Dar Yasmine' })).toEqual({
            primary: 'Dar Yasmine',
            secondary: null,
            disambiguation: null,
        });
    });

    it("libellé partagé : l'identifiant d'abord, puis le nom complet, sur une ligne permanente qui remplace la seconde", () => {
        expect(listingNames({ uid: '3360312', name: 'Sunset 203 | Luxury Apt + Gym Lac 1 Tunis', internal_name: 'Sunset 203' }, true)).toEqual({
            primary: 'Sunset 203',
            secondary: null,
            disambiguation: '3360312 · Sunset 203 | Luxury Apt + Gym Lac 1 Tunis',
        });
    });

    it("libellé partagé sans nom interne : l'identifiant seul", () => {
        expect(listingNames({ uid: '4948034', name: 'Rd Zephyr Marsa Corniche 2 bedrooms', internal_name: null }, true)).toEqual({
            primary: 'Rd Zephyr Marsa Corniche 2 bedrooms',
            secondary: null,
            disambiguation: '4948034',
        });
    });
});

/*
 * Deux logements sous le même libellé, chacun avec son interrupteur et ses prix
 * actifs : les cas viennent de la base réelle (instantané de dev).
 */
describe('ambiguousListingUids — libellés partagés', () => {
    const sunset203 = { uid: '3360312', name: 'Sunset 203 | Luxury Apt + Gym Lac 1 Tunis', internal_name: 'Sunset 203' };
    const sunset204 = { uid: '3360305', name: 'Sunset 204 | Luxury 2BR + Gym Tunisia', internal_name: 'Sunset 203' };
    const zephyrA = { uid: '3347542', name: 'Rd Zephyr Marsa Corniche 2 bedrooms', internal_name: null };
    const zephyrB = { uid: '4948034', name: 'Rd Zephyr Marsa Corniche 2 bedrooms', internal_name: null };
    const alone = { uid: '101', name: 'Dar Yasmine', internal_name: 'Yasmine' };

    it('deux noms internes identiques', () => {
        expect(ambiguousListingUids([sunset203, sunset204, alone])).toEqual(new Set(['3360312', '3360305']));
    });

    it('deux noms complets identiques, sans nom interne', () => {
        expect(ambiguousListingUids([zephyrA, zephyrB, alone])).toEqual(new Set(['3347542', '4948034']));
    });

    it("un nom interne qui vaut le nom complet d'un autre logement", () => {
        const named = { uid: '201', name: 'Dar Yasmine', internal_name: null };
        expect(ambiguousListingUids([alone, { uid: '202', name: 'Villa Yasmine', internal_name: 'Dar Yasmine' }, named])).toEqual(
            new Set(['201', '202']),
        );
    });

    it("la casse et les espaces ne distinguent rien à l'œil : ils ne distinguent rien ici", () => {
        expect(ambiguousListingUids([sunset203, { ...sunset204, internal_name: '  sunset   203 ' }])).toEqual(new Set(['3360312', '3360305']));
    });

    it('des libellés tous différents : personne', () => {
        expect(ambiguousListingUids([sunset203, zephyrA, alone])).toEqual(new Set());
        expect(ambiguousListingUids([])).toEqual(new Set());
    });

    it("chaque ligne d'un groupe ambigu devient distincte", () => {
        const rows = [sunset203, sunset204, zephyrA, zephyrB, alone];
        const ambiguous = ambiguousListingUids(rows);
        const shown = rows.map((row) => {
            const names = listingNames(row, ambiguous.has(row.uid));
            return `${names.primary} / ${names.disambiguation ?? ''}`;
        });
        expect(new Set(shown).size).toBe(rows.length);
    });

    it('listingIdentity nomme le logement par son libellé et son identifiant (fenêtre de restauration)', () => {
        expect(listingIdentity(sunset204)).toBe('Sunset 203 · 3360305');
        expect(listingIdentity(zephyrB)).toBe('Rd Zephyr Marsa Corniche 2 bedrooms · 4948034');
    });
});

describe('pinnedTourAnchors — ancres de la visite selon la disposition', () => {
    const rows = [{ uid: 'A' }, { uid: 'B' }];

    it('pleine largeur : première ligne et en-tête Min', () => {
        expect(pinnedTourAnchors(computePinnedLayout(PINNED_FULL_WIDTH), rows)).toEqual({ rowUid: 'A', band: true });
    });

    it('colonne yield masquée : plus d’ancre de ligne (elle mesurerait 0 × 0)', () => {
        const layout = computePinnedLayout(331);
        expect(layout.visible.yield).toBe(false);
        expect(pinnedTourAnchors(layout, rows).rowUid).toBeNull();
    });

    it('Min masquée mais yield affichée : ancre de ligne oui, ancre de bande non', () => {
        const layout = computePinnedLayout(332);
        expect(pinnedTourAnchors(layout, rows)).toEqual({ rowUid: 'A', band: false });
    });

    it('liste vide : aucune ancre de ligne, même à pleine largeur', () => {
        expect(pinnedTourAnchors(computePinnedLayout(PINNED_FULL_WIDTH), []).rowUid).toBeNull();
    });

    it('à chaque largeur, les ancres suivent exactement la visibilité de leur colonne', () => {
        for (const width of EVERY_WIDTH) {
            const layout = computePinnedLayout(width);
            const anchors = pinnedTourAnchors(layout, rows);
            expect(anchors.rowUid === 'A', `largeur ${width}`).toBe(layout.visible.yield);
            expect(anchors.band, `largeur ${width}`).toBe(layout.visible.min);
        }
    });
});

/*
 * Le glissement. C'est la MESURE de l'exigence de performance : `commit` est
 * le seul chemin vers `setState` (voir usePinnedWidth) — tant qu'il n'est
 * appelé qu'au relâchement, aucun pointermove ne peut provoquer de rendu React,
 * donc aucune des ~121 lignes mémoïsées ne se re-rend pendant le geste.
 */
describe('createPinnedResizeGesture', () => {
    /** Horloge d'images manuelle : rien ne s'exécute tant qu'on ne « peint » pas. */
    function manualFrames() {
        let nextId = 1;
        const queue = new Map<number, () => void>();
        return {
            request: vi.fn((callback: () => void) => {
                const id = nextId++;
                queue.set(id, callback);
                return id;
            }),
            cancel: vi.fn((id: number) => {
                queue.delete(id);
            }),
            paint() {
                const callbacks = [...queue.values()];
                queue.clear();
                callbacks.forEach((callback) => callback());
            },
            get pending() {
                return queue.size;
            },
        };
    }

    function setup() {
        const frames = manualFrames();
        const apply = vi.fn();
        const commit = vi.fn();
        const gesture = createPinnedResizeGesture({ apply, commit, frames });
        return { frames, apply, commit, gesture };
    }

    it('un long glissement : AUCUN commit pendant le geste, un seul au relâchement', () => {
        const { frames, apply, commit, gesture } = setup();

        // ~2 s de souris à 120 Hz, avec une image toutes les deux positions (60 i/s).
        gesture.begin(1000, PINNED_FULL_WIDTH);
        for (let i = 1; i <= 240; i += 1) {
            gesture.move(1000 - i * 2);
            expect(commit).not.toHaveBeenCalled();
            if (i % 2 === 0) {
                frames.paint();
            }
        }

        expect(commit).not.toHaveBeenCalled();
        // Au plus une écriture DOM par image, jamais une par événement.
        expect(apply.mock.calls.length).toBeLessThanOrEqual(120);
        expect(apply).toHaveBeenLastCalledWith(PINNED_FULL_WIDTH - 480);

        gesture.end();

        expect(commit).toHaveBeenCalledTimes(1);
        expect(commit).toHaveBeenCalledWith(PINNED_FULL_WIDTH - 480);
    });

    it("plusieurs mouvements dans la même image ne produisent qu'une écriture, la dernière", () => {
        const { frames, apply, gesture } = setup();

        gesture.begin(500, 800);
        gesture.move(490);
        gesture.move(480);
        gesture.move(470);
        expect(apply).not.toHaveBeenCalled();
        expect(frames.request).toHaveBeenCalledTimes(1);

        frames.paint();
        expect(apply).toHaveBeenCalledTimes(1);
        expect(apply).toHaveBeenCalledWith(770);
    });

    it('relâcher avant la prochaine image applique quand même la dernière position, puis la valide', () => {
        const { frames, apply, commit, gesture } = setup();

        gesture.begin(500, 800);
        gesture.move(450);
        gesture.end();

        expect(frames.cancel).toHaveBeenCalledTimes(1);
        expect(frames.pending).toBe(0);
        expect(apply).toHaveBeenCalledWith(750);
        expect(commit).toHaveBeenCalledWith(750);
    });

    it('un simple clic (sans mouvement) ne valide rien — ni état, ni mémoire', () => {
        const { apply, commit, gesture } = setup();

        gesture.begin(500, 600);
        gesture.end();

        expect(apply).not.toHaveBeenCalled();
        expect(commit).not.toHaveBeenCalled();
    });

    it('revenir à la largeur de départ ne valide rien', () => {
        const { frames, commit, gesture } = setup();

        gesture.begin(500, 600);
        gesture.move(400);
        frames.paint();
        gesture.move(500);
        frames.paint();
        gesture.end();

        expect(commit).not.toHaveBeenCalled();
    });

    it('la largeur suit les bornes, même en tirant très loin', () => {
        const { frames, apply, commit, gesture } = setup();

        gesture.begin(900, 600);
        gesture.move(-5000);
        frames.paint();
        expect(apply).toHaveBeenLastCalledWith(PINNED_MIN_WIDTH);

        gesture.move(9000);
        frames.paint();
        expect(apply).toHaveBeenLastCalledWith(PINNED_FULL_WIDTH);

        gesture.end();
        expect(commit).toHaveBeenCalledWith(PINNED_FULL_WIDTH);
    });

    it('end est idempotent (pointerup puis lostpointercapture) et les mouvements après la fin sont ignorés', () => {
        const { frames, apply, commit, gesture } = setup();

        gesture.begin(500, 600);
        gesture.move(450);
        gesture.end();
        gesture.end();
        gesture.move(100);
        frames.paint();

        expect(commit).toHaveBeenCalledTimes(1);
        expect(apply).toHaveBeenCalledTimes(1);
        expect(gesture.isActive()).toBe(false);
    });

    it('un mouvement sans begin ne fait rien', () => {
        const { frames, apply, gesture } = setup();

        gesture.move(100);
        frames.paint();

        expect(frames.request).not.toHaveBeenCalled();
        expect(apply).not.toHaveBeenCalled();
    });
});

describe('setTextSelectionLocked', () => {
    function fakeRoot() {
        const style: Record<string, string> = { color: 'red' };
        return {
            style,
            root: {
                style: {
                    setProperty: (property: string, value: string) => {
                        style[property] = value;
                    },
                    removeProperty: (property: string) => {
                        const previous = style[property] ?? '';
                        delete style[property];
                        return previous;
                    },
                },
            },
        };
    }

    it('verrouille les deux écritures (Safari ne lit que la préfixée), puis les RETIRE sans toucher au reste', () => {
        const { style, root } = fakeRoot();
        setTextSelectionLocked(root, true);
        expect(style).toEqual({ color: 'red', 'user-select': 'none', '-webkit-user-select': 'none' });
        setTextSelectionLocked(root, false);
        expect(style).toEqual({ color: 'red' });
        // Libérer deux fois (relâchement puis perte de capture) ne casse rien.
        setTextSelectionLocked(root, false);
        expect(style).toEqual({ color: 'red' });
    });

    it('sans racine (rendu serveur) : rien à faire, rien ne lève', () => {
        expect(() => setTextSelectionLocked(null, true)).not.toThrow();
        expect(() => setTextSelectionLocked(undefined, false)).not.toThrow();
    });
});

/*
 * Le comportement de la barre tel que le hook le branche : ces tests sont les
 * seuls à atteindre la mémorisation, le double-clic, le clavier et le filtre
 * « bouton principal » — `usePinnedWidth` n'en est qu'une enveloppe React.
 */
describe('createPinnedWidthController', () => {
    function setup(stored: Record<string, string> = {}) {
        const data: Record<string, string> = { ...stored };
        const storage = {
            getItem: vi.fn((key: string) => (key in data ? data[key] : null)),
            setItem: vi.fn((key: string, value: string) => {
                data[key] = value;
            }),
        };
        const queue: (() => void)[] = [];
        const frames = {
            request: vi.fn((callback: () => void) => queue.push(callback)),
            cancel: vi.fn(),
        };
        const apply = vi.fn();
        const setResizing = vi.fn();
        const setWidth = vi.fn();
        const controller = createPinnedWidthController({ storage: () => storage, frames, apply, setResizing, setWidth });
        const paint = () => queue.splice(0).forEach((callback) => callback());
        const capture = vi.fn();
        return { controller, storage, data, frames, apply, setResizing, setWidth, paint, capture };
    }

    it('part de la largeur complète, sans lire le stockage avant le montage (rendu serveur)', () => {
        const { controller, storage, setWidth } = setup({ [PINNED_WIDTH_STORAGE_KEY]: '400' });
        expect(controller.width()).toBe(PINNED_FULL_WIDTH);
        expect(storage.getItem).not.toHaveBeenCalled();
        expect(setWidth).not.toHaveBeenCalled();
    });

    it('au montage : relit la largeur mémorisée et la valide dans l’état, sans rien réécrire', () => {
        const { controller, storage, setWidth } = setup({ [PINNED_WIDTH_STORAGE_KEY]: '400' });
        controller.mount();
        expect(storage.getItem).toHaveBeenCalledWith(PINNED_WIDTH_STORAGE_KEY);
        expect(setWidth).toHaveBeenCalledExactlyOnceWith(400);
        expect(controller.width()).toBe(400);
        expect(storage.setItem).not.toHaveBeenCalled();
    });

    it('au montage, rien de mémorisé (ou une valeur invalide) : aucun rendu de plus', () => {
        const cases: Record<string, string>[] = [{}, { [PINNED_WIDTH_STORAGE_KEY]: 'large' }];
        for (const stored of cases) {
            const { controller, setWidth } = setup(stored);
            controller.mount();
            expect(setWidth).not.toHaveBeenCalled();
            expect(controller.width()).toBe(PINNED_FULL_WIDTH);
        }
    });

    it('un glissement : écrit le DOM pendant le geste, et au relâchement valide ET mémorise UNE fois', () => {
        const { controller, storage, data, apply, setResizing, setWidth, paint, capture } = setup();

        controller.pointerDown({ button: 0, clientX: 1000, capture });
        expect(capture).toHaveBeenCalledTimes(1);
        expect(setResizing).toHaveBeenLastCalledWith(true);

        for (let x = 997; x >= 700; x -= 3) {
            controller.pointerMove(x);
            paint();
        }
        expect(apply).toHaveBeenCalled();
        expect(setWidth).not.toHaveBeenCalled();
        expect(storage.setItem).not.toHaveBeenCalled();

        controller.pointerEnd();
        const expected = PINNED_FULL_WIDTH - 300;
        expect(setWidth).toHaveBeenCalledExactlyOnceWith(expected);
        expect(storage.setItem).toHaveBeenCalledExactlyOnceWith(PINNED_WIDTH_STORAGE_KEY, String(expected));
        expect(data[PINNED_WIDTH_STORAGE_KEY]).toBe(String(expected));
        expect(setResizing).toHaveBeenLastCalledWith(false);
        expect(controller.width()).toBe(expected);

        // pointerup puis lostpointercapture : la seconde fin ne refait rien.
        controller.pointerEnd();
        expect(setWidth).toHaveBeenCalledTimes(1);
        expect(storage.setItem).toHaveBeenCalledTimes(1);
    });

    /*
     * Démonté en plein glissement (navigation au clavier, retour du
     * navigateur) : aucun relâchement n'arrivera plus. Sans cette sortie, la
     * page suivante héritait d'un document où plus rien ne se sélectionne.
     * Le nettoyage d'effet du hook n'est jamais exécuté en test (rendu
     * serveur) : c'est ici qu'il est prouvé.
     */
    it('démonté en plein geste : le geste est clos et le verrou de sélection retiré, une seule fois', () => {
        const style: Record<string, string> = {};
        const root = {
            style: {
                setProperty: (property: string, value: string) => {
                    style[property] = value;
                },
                removeProperty: (property: string) => {
                    const previous = style[property] ?? '';
                    delete style[property];
                    return previous;
                },
            },
        };
        const { controller, frames, setResizing, setWidth, capture } = setup();
        // Le même branchement que le hook : le verrou suit `setResizing`.
        setResizing.mockImplementation((resizing: boolean) => setTextSelectionLocked(root, resizing));

        controller.pointerDown({ button: 0, clientX: 800, capture });
        controller.pointerMove(700);
        expect(style['user-select']).toBe('none');

        controller.unmount();
        expect(setResizing).toHaveBeenLastCalledWith(false);
        expect(style).toEqual({});
        // L'image demandée par le mouvement est annulée : rien ne tombera après le démontage.
        expect(frames.cancel).toHaveBeenCalledTimes(1);
        // Ce que l'utilisateur voyait est validé, comme au relâchement.
        expect(setWidth).toHaveBeenCalledExactlyOnceWith(PINNED_FULL_WIDTH - 100);

        // La perte de capture qui suit éventuellement ne refait rien.
        controller.pointerEnd();
        controller.unmount();
        expect(setResizing).toHaveBeenCalledTimes(2);
        expect(setWidth).toHaveBeenCalledTimes(1);
    });

    it('démonté hors geste : rien — ni verrou retiré, ni rendu, ni écriture', () => {
        const { controller, setResizing, setWidth, storage } = setup();
        controller.unmount();
        expect(setResizing).not.toHaveBeenCalled();
        expect(setWidth).not.toHaveBeenCalled();
        expect(storage.setItem).not.toHaveBeenCalled();
    });

    it('le geste suivant repart de la largeur validée', () => {
        const { controller, setWidth, paint, capture } = setup();
        controller.pointerDown({ button: 0, clientX: 500, capture });
        controller.pointerMove(400);
        paint();
        controller.pointerEnd();

        controller.pointerDown({ button: 0, clientX: 500, capture });
        controller.pointerMove(450);
        paint();
        controller.pointerEnd();

        expect(setWidth).toHaveBeenLastCalledWith(PINNED_FULL_WIDTH - 150);
    });

    it('pointerDown dit à l’appelant si le geste démarre — c’est ce qui décide du preventDefault', () => {
        const { controller, capture } = setup();
        expect(controller.pointerDown({ button: 2, clientX: 800, capture })).toBe(false);
        expect(controller.pointerDown({ button: 1, clientX: 800, capture })).toBe(false);
        expect(controller.pointerDown({ button: 0, clientX: 800, capture })).toBe(true);
    });

    it('le geste démarre même si la capture lève : l’appui doit quand même être annulé', () => {
        const { controller } = setup();
        const started = controller.pointerDown({
            button: 0,
            clientX: 800,
            capture: () => {
                throw new DOMException('No active pointer', 'NotFoundError');
            },
        });
        expect(started).toBe(true);
    });

    it('seul le bouton principal démarre un glissement (clic droit, clic du milieu : rien)', () => {
        for (const button of [1, 2]) {
            const { controller, apply, setResizing, setWidth, storage, paint, capture } = setup();
            controller.pointerDown({ button, clientX: 800, capture });
            controller.pointerMove(300);
            paint();
            controller.pointerEnd();
            expect(capture).not.toHaveBeenCalled();
            expect(setResizing).not.toHaveBeenCalled();
            expect(apply).not.toHaveBeenCalled();
            expect(setWidth).not.toHaveBeenCalled();
            expect(storage.setItem).not.toHaveBeenCalled();
        }
    });

    it('une capture qui lève (pointeur déjà relâché) ne casse pas le geste', () => {
        const { controller, setWidth, paint } = setup();
        controller.pointerDown({
            button: 0,
            clientX: 800,
            capture: () => {
                throw new DOMException('No active pointer', 'NotFoundError');
            },
        });
        controller.pointerMove(700);
        paint();
        controller.pointerEnd();
        expect(setWidth).toHaveBeenCalledWith(PINNED_FULL_WIDTH - 100);
    });

    it('double-clic : retour à la largeur complète, validé et mémorisé', () => {
        const { controller, storage, apply, setWidth } = setup({ [PINNED_WIDTH_STORAGE_KEY]: '400' });
        controller.mount();
        setWidth.mockClear();

        controller.doubleClick();

        expect(apply).toHaveBeenLastCalledWith(PINNED_FULL_WIDTH);
        expect(setWidth).toHaveBeenCalledExactlyOnceWith(PINNED_FULL_WIDTH);
        expect(storage.setItem).toHaveBeenCalledExactlyOnceWith(PINNED_WIDTH_STORAGE_KEY, String(PINNED_FULL_WIDTH));
        expect(controller.width()).toBe(PINNED_FULL_WIDTH);
    });

    it('double-clic déjà à pleine largeur : aucun rendu, aucune écriture', () => {
        const { controller, storage, setWidth } = setup();
        controller.doubleClick();
        expect(setWidth).not.toHaveBeenCalled();
        expect(storage.setItem).not.toHaveBeenCalled();
    });

    it.each([
        ['ArrowLeft', PINNED_FULL_WIDTH - PINNED_RESIZE_STEP],
        ['Home', PINNED_MIN_WIDTH],
    ])('clavier %s : pris en charge (la page ne doit pas défiler), validé et mémorisé', (key, expected) => {
        const { controller, storage, setWidth } = setup();
        expect(controller.keyDown(key)).toBe(true);
        expect(setWidth).toHaveBeenCalledExactlyOnceWith(expected);
        expect(storage.setItem).toHaveBeenCalledExactlyOnceWith(PINNED_WIDTH_STORAGE_KEY, String(expected));
    });

    it('clavier ArrowRight / End depuis une largeur réduite : pris en charge', () => {
        const { controller, setWidth } = setup({ [PINNED_WIDTH_STORAGE_KEY]: '400' });
        controller.mount();
        expect(controller.keyDown('ArrowRight')).toBe(true);
        expect(setWidth).toHaveBeenLastCalledWith(400 + PINNED_RESIZE_STEP);
        expect(controller.keyDown('End')).toBe(true);
        expect(setWidth).toHaveBeenLastCalledWith(PINNED_FULL_WIDTH);
    });

    it('Fin à pleine largeur : la touche reste prise en charge (sinon la page défilerait), sans rendu', () => {
        const { controller, setWidth } = setup();
        expect(controller.keyDown('End')).toBe(true);
        expect(setWidth).not.toHaveBeenCalled();
    });

    it('les autres touches ne sont pas prises en charge : Tab doit continuer à déplacer le focus', () => {
        const { controller, apply, setWidth } = setup();
        for (const key of ['Tab', 'Enter', ' ', 'ArrowUp', 'a']) {
            expect(controller.keyDown(key), key).toBe(false);
        }
        expect(apply).not.toHaveBeenCalled();
        expect(setWidth).not.toHaveBeenCalled();
    });

    it('un stockage bloqué ne casse ni le montage ni la validation', () => {
        const setWidth = vi.fn();
        const blocked = () => {
            throw new DOMException('The operation is insecure.', 'SecurityError');
        };
        const controller = createPinnedWidthController({
            storage: blocked,
            frames: { request: () => 0, cancel: () => {} },
            apply: () => {},
            setResizing: () => {},
            setWidth,
        });
        controller.mount();
        expect(controller.keyDown('Home')).toBe(true);
        expect(setWidth).toHaveBeenCalledExactlyOnceWith(PINNED_MIN_WIDTH);
    });
});
