import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { createElement, createRef } from 'react';
import { renderToString } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PINNED_FULL_WIDTH, PINNED_MIN_WIDTH, PINNED_WIDTH_STORAGE_KEY } from '../lib/pinned-layout';
import { usePinnedWidth, type PinnedWidthControls } from './usePinnedWidth';

/**
 * Le BRANCHEMENT de `usePinnedWidth` : le comportement est testé dans
 * `createPinnedWidthController` (pinned-layout.test.ts) ; ici, on vérifie que
 * le hook le relie bien aux événements React — touche prise en charge ⇒
 * `preventDefault`, bouton principal seulement ⇒ capture du pointeur,
 * `preventDefault` (pas de sélection de texte) et focus rendu à la barre,
 * sélection du document verrouillée le temps du geste, relâchement et
 * double-clic ⇒ mémorisation.
 *
 * Sans DOM de test dans le projet, le hook est rendu par `react-dom/server` :
 * c'est aussi la preuve qu'il ne touche pas à `window` pendant le rendu
 * serveur. On récupère ensuite ses gestionnaires et on les appelle avec des
 * événements minimaux, `window` étant alors simulé (stockage + images).
 */

function renderHook(): PinnedWidthControls {
    let controls: PinnedWidthControls | undefined;
    function Probe() {
        controls = usePinnedWidth({ emptyCellRef: createRef<HTMLTableCellElement>(), trailingColumns: 90 });
        return null;
    }
    renderToString(createElement(Probe));
    return controls!;
}

function fakeBrowser() {
    const data: Record<string, string> = {};
    const localStorage = {
        getItem: vi.fn((key: string) => (key in data ? data[key] : null)),
        setItem: vi.fn((key: string, value: string) => {
            data[key] = value;
        }),
    };
    const frames: (() => void)[] = [];
    vi.stubGlobal('window', {
        localStorage,
        requestAnimationFrame: (callback: () => void) => frames.push(callback),
        cancelAnimationFrame: () => {},
    });
    // Le style de la racine du document : seul ce que le hook y écrit compte.
    const rootStyle: Record<string, string> = {};
    vi.stubGlobal('document', {
        documentElement: {
            style: {
                setProperty: (property: string, value: string) => {
                    rootStyle[property] = value;
                },
                removeProperty: (property: string) => {
                    const previous = rootStyle[property] ?? '';
                    delete rootStyle[property];
                    return previous;
                },
            },
        },
    });
    return { localStorage, rootStyle, paint: () => frames.splice(0).forEach((callback) => callback()) };
}

function keyEvent(key: string) {
    return { key, preventDefault: vi.fn() } as unknown as Parameters<PinnedWidthControls['separatorHandlers']['onKeyDown']>[0] & {
        preventDefault: ReturnType<typeof vi.fn>;
    };
}

function pointerEvent(button: number, clientX: number) {
    const setPointerCapture = vi.fn();
    const focus = vi.fn();
    const preventDefault = vi.fn();
    const event = { button, clientX, pointerId: 7, preventDefault, currentTarget: { setPointerCapture, focus } } as unknown as Parameters<
        PinnedWidthControls['separatorHandlers']['onPointerDown']
    >[0];
    return { event, setPointerCapture, focus, preventDefault };
}

afterEach(() => {
    vi.unstubAllGlobals();
});

describe('usePinnedWidth — branchement des événements', () => {
    it('se rend côté serveur sans window : pleine largeur, rien lu ni écrit', () => {
        expect(typeof window).toBe('undefined');
        const { layout } = renderHook();
        expect(layout.width).toBe(PINNED_FULL_WIDTH);
    });

    it('clavier : une touche de la barre empêche le défilement de la page et mémorise, Tab passe son chemin', () => {
        const { separatorHandlers } = renderHook();
        const { localStorage } = fakeBrowser();

        const home = keyEvent('Home');
        separatorHandlers.onKeyDown(home);
        expect(home.preventDefault).toHaveBeenCalledTimes(1);
        expect(localStorage.setItem).toHaveBeenCalledExactlyOnceWith(PINNED_WIDTH_STORAGE_KEY, String(PINNED_MIN_WIDTH));

        const tab = keyEvent('Tab');
        separatorHandlers.onKeyDown(tab);
        expect(tab.preventDefault).not.toHaveBeenCalled();
    });

    it('double-clic : retour à la largeur complète, mémorisé', () => {
        const { separatorHandlers } = renderHook();
        const { localStorage } = fakeBrowser();

        separatorHandlers.onKeyDown(keyEvent('Home'));
        separatorHandlers.onDoubleClick();

        expect(localStorage.setItem).toHaveBeenLastCalledWith(PINNED_WIDTH_STORAGE_KEY, String(PINNED_FULL_WIDTH));
    });

    it('glissement : bouton principal seulement, capture du pointeur, mémorisation au seul relâchement', () => {
        const { separatorHandlers } = renderHook();
        const { localStorage, paint } = fakeBrowser();

        const right = pointerEvent(2, 800);
        separatorHandlers.onPointerDown(right.event);
        expect(right.setPointerCapture).not.toHaveBeenCalled();
        // Un clic droit garde son comportement ordinaire (menu contextuel).
        expect(right.preventDefault).not.toHaveBeenCalled();
        expect(right.focus).not.toHaveBeenCalled();

        const main = pointerEvent(0, 800);
        separatorHandlers.onPointerDown(main.event);
        expect(main.setPointerCapture).toHaveBeenCalledExactlyOnceWith(7);

        separatorHandlers.onPointerMove(pointerEvent(0, 600).event);
        paint();
        expect(localStorage.setItem).not.toHaveBeenCalled();

        separatorHandlers.onPointerUp();
        expect(localStorage.setItem).toHaveBeenCalledExactlyOnceWith(PINNED_WIDTH_STORAGE_KEY, String(PINNED_FULL_WIDTH - 200));

        // La perte de capture qui suit le relâchement ne refait rien.
        separatorHandlers.onLostPointerCapture();
        separatorHandlers.onPointerCancel();
        expect(localStorage.setItem).toHaveBeenCalledTimes(1);
    });

    /*
     * Défaut vu à l'écran : en tirant la barre, le texte des cellules de prix
     * se surlignait en bleu. Le navigateur faisait de l'appui le début d'une
     * sélection de texte, que `select-none` sur la barre n'arrêtait pas.
     */
    it('glissement : pas de sélection de texte — appui annulé, focus rendu à la barre, document verrouillé jusqu’au relâchement', () => {
        const { separatorHandlers } = renderHook();
        const { rootStyle, paint } = fakeBrowser();

        const main = pointerEvent(0, 800);
        separatorHandlers.onPointerDown(main.event);
        expect(main.preventDefault).toHaveBeenCalledTimes(1);
        // Annuler l'appui annule aussi le focus qu'il donnait : les flèches doivent marcher juste après.
        expect(main.focus).toHaveBeenCalledExactlyOnceWith({ preventScroll: true });
        expect(rootStyle).toEqual({ 'user-select': 'none', '-webkit-user-select': 'none' });

        separatorHandlers.onPointerMove(pointerEvent(0, 700).event);
        paint();
        expect(rootStyle['user-select']).toBe('none');

        separatorHandlers.onPointerUp();
        expect(rootStyle).toEqual({});
    });

    it.each([
        ['annulation (pointercancel)', 'onPointerCancel'],
        ['perte de capture', 'onLostPointerCapture'],
    ] as const)('%s : la sélection de texte est rendue au document', (_, exit) => {
        const { separatorHandlers } = renderHook();
        const { rootStyle } = fakeBrowser();

        separatorHandlers.onPointerDown(pointerEvent(0, 800).event);
        expect(rootStyle['user-select']).toBe('none');

        separatorHandlers[exit]();
        expect(rootStyle).toEqual({});
    });

    it('un clic droit ne verrouille rien', () => {
        const { separatorHandlers } = renderHook();
        const { rootStyle } = fakeBrowser();

        separatorHandlers.onPointerDown(pointerEvent(2, 800).event);
        expect(rootStyle).toEqual({});
    });

    /*
     * Démonté en plein geste, le verrou de sélection doit tomber (sans quoi la
     * page suivante ne se sélectionne plus). `renderToString` n'exécute aucun
     * effet ni nettoyage : le comportement est prouvé sur le contrôleur
     * (pinned-layout.test.ts, « démonté en plein geste ») ; ici, que le hook
     * l'appelle bien au démontage.
     */
    it('au démontage, le nettoyage d’effet confie la sortie au contrôleur', () => {
        const source = readFileSync(resolve(__dirname, 'usePinnedWidth.ts'), 'utf8');
        expect(source).toMatch(/useEffect\(\(\) => \(\) => controller\.unmount\(\), \[controller\]\);/);
    });
});
