import { createElement, type ComponentProps } from 'react';
import { renderToString } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
    HIDE_ORDER,
    PINNED_CELL_CLASS,
    PINNED_COLUMNS,
    PINNED_FULL_WIDTH,
    PINNED_MIN_WIDTH,
    ambiguousListingUids,
    computePinnedLayout,
    type PinnedColumn,
} from '../lib/pinned-layout';
import type { YieldTableRow } from '../types';
import YieldTable from './YieldTable';

/**
 * YieldTable tel qu'il sort du rendu SERVEUR (`react-dom/server`, disponible
 * en environnement `node` — le projet n'a ni jsdom ni @testing-library).
 *
 * `pinned-layout.test.ts` prouve que la GÉOMÉTRIE est juste ; ce test prouve
 * que le composant l'APPLIQUE : chaque cellule porte les classes de SA colonne,
 * le `colSpan` de la ligne vide suit les colonnes rendues, le nom interne passe
 * en premier, le nom complet reste masqué hors `data-name-detail`, un libellé
 * partagé montre l'identifiant, et les ancres de la visite suivent leur colonne.
 *
 * Le hook réel tourne à chaque rendu (il ne doit toucher ni `window` ni le
 * stockage côté serveur) ; pour les largeurs réduites, on remplace seulement la
 * disposition qu'il rend — au premier rendu, il est toujours à pleine largeur.
 */

const page = vi.hoisted(() => ({ permissions: ['yield.view', 'yield.manage'] as string[] }));
const forced = vi.hoisted(() => ({ width: null as number | null }));

vi.mock('@inertiajs/react', () => ({
    usePage: () => ({ props: { auth: { permissions: page.permissions }, translations: {}, locale: 'fr' } }),
    router: { post: vi.fn(), patch: vi.fn(), visit: vi.fn() },
}));

vi.mock('../hooks/usePinnedWidth', async (importOriginal) => {
    const real = await importOriginal<typeof import('../hooks/usePinnedWidth')>();
    const layoutLib = await import('../lib/pinned-layout');
    return {
        ...real,
        usePinnedWidth: (options: Parameters<typeof real.usePinnedWidth>[0]) => {
            const controls = real.usePinnedWidth(options);
            return forced.width === null ? controls : { ...controls, layout: layoutLib.computePinnedLayout(forced.width) };
        },
    };
});

afterEach(() => {
    forced.width = null;
    page.permissions = ['yield.view', 'yield.manage'];
});

type Props = ComponentProps<typeof YieldTable>;

function row(overrides: Partial<YieldTableRow> = {}): YieldTableRow {
    return {
        id: 1,
        uid: '100',
        name: 'Villa des Artistes — Hammamet Nord',
        internal_name: null,
        is_active: true,
        has_config: true,
        base_price: 100,
        min_price: 80,
        max_price: 150,
        last_push_status: null,
        last_pushed_at: null,
        occupancy_pct: 50,
        rule_group_id: null,
        rule_group_name: null,
        current_price: null,
        has_backup: false,
        ...overrides,
    };
}

const noop = () => {};

function render(props: Partial<Props> = {}, width: number | null = null): string {
    forced.width = width;
    const rows = props.rows ?? [];
    return renderToString(
        createElement(YieldTable, {
            rows,
            exchangeRate: 3.1,
            selected: new Set<string>(),
            series: {},
            ruleGroups: [],
            onToggleRow: noop,
            onSelectAll: noop,
            onClearAll: noop,
            onActivateSingle: noop,
            onConfigure: noop,
            onPushNow: noop,
            onRestore: noop,
            ambiguousNames: ambiguousListingUids(rows),
            ...props,
        }),
    );
}

/** Les `<tr>` du tableau, en-tête d'abord. */
function tableRows(html: string): string[] {
    return html.match(/<tr\b[^>]*>[\s\S]*?<\/tr>/g) ?? [];
}

interface Cell {
    tag: 'td' | 'th';
    attrs: string;
    classes: Set<string>;
    /** Du début de la cellule au début de la suivante — assez pour lire son contenu. */
    html: string;
}

function cells(rowHtml: string): Cell[] {
    const opens = [...rowHtml.matchAll(/<(td|th)\b([^>]*)>/g)];
    return opens.map((match, i) => {
        const attrs = match[2];
        const cls = /\bclass="([^"]*)"/.exec(attrs)?.[1] ?? '';
        const end = i + 1 < opens.length ? opens[i + 1].index : rowHtml.length;
        return { tag: match[1] as 'td' | 'th', attrs, classes: new Set(cls.split(/\s+/).filter(Boolean)), html: rowHtml.slice(match.index, end) };
    });
}

const tokens = (col: PinnedColumn) => PINNED_CELL_CLASS[col].split(/\s+/);
/** La classe `left-[…]` d'une colonne : la marque qui l'identifie sans ambiguïté. */
const leftToken = (col: PinnedColumn) => tokens(col).find((token) => token.startsWith('left-['))!;

function expectPinnedCellsInOrder(rowCells: Cell[], where: string) {
    PINNED_COLUMNS.forEach((col, i) => {
        const cell = rowCells[i];
        for (const token of tokens(col)) {
            expect(cell.classes.has(token), `${where}, cellule ${i} (${col}) : classe ${token} absente`).toBe(true);
        }
        for (const other of PINNED_COLUMNS.filter((c) => c !== col)) {
            expect(cell.classes.has(leftToken(other)), `${where}, cellule ${i} (${col}) porte la géométrie de ${other}`).toBe(false);
        }
    });
}

function countOf(html: string, needle: string): number {
    return html.split(needle).length - 1;
}

describe('YieldTable — rendu serveur', () => {
    it('se rend côté serveur sans toucher à window ni au stockage (hook réel, pleine largeur)', () => {
        expect(typeof window).toBe('undefined');
        const html = render({ rows: [row()] });
        expect(html).toContain(`--yt-pinned-w:${PINNED_FULL_WIDTH}px`);
        expect(html).toContain(`aria-valuenow="${PINNED_FULL_WIDTH}"`);
        expect(html).toContain('data-name-detail=""');
    });

    it("chaque cellule figée — en-tête ET corps — porte les classes de SA colonne, dans l'ordre", () => {
        const html = render({
            rows: [
                row({ uid: '1', has_backup: true, rule_group_id: 3, rule_group_name: 'Été' }),
                row({ uid: '2', id: null, has_config: false, is_active: false, current_price: 250 }),
            ],
        });
        const [head, ...body] = tableRows(html);
        expect(body).toHaveLength(2);

        expectPinnedCellsInOrder(cells(head), 'en-tête');
        body.forEach((bodyRow, i) => expectPinnedCellsInOrder(cells(bodyRow), `ligne ${i + 1}`));
    });

    it('les cellules des nuits ne portent aucune géométrie figée', () => {
        const [head, first] = tableRows(render({ rows: [row()] }));
        const allLeftTokens = PINNED_COLUMNS.map(leftToken);
        for (const [label, rowHtml] of [
            ['en-tête', head],
            ['ligne', first],
        ] as const) {
            const dayCells = cells(rowHtml).slice(PINNED_COLUMNS.length);
            expect(dayCells.length, label).toBeGreaterThan(80);
            for (const cell of dayCells) {
                expect(
                    allLeftTokens.some((token) => cell.classes.has(token)),
                    label,
                ).toBe(false);
            }
        }
    });

    it.each([PINNED_FULL_WIDTH, 831, 500, 332, 331, 204, 203, PINNED_MIN_WIDTH])(
        'largeur %i : la ligne « aucun logement » couvre les colonnes figées RENDUES + les nuits',
        (width) => {
            const html = render({ rows: [] }, width);
            const [head, empty] = tableRows(html);
            const dayCount = cells(head).length - PINNED_COLUMNS.length;
            const colspan = Number(/\bcolspan="(\d+)"/i.exec(empty)?.[1]);
            expect(colspan).toBe(computePinnedLayout(width).visibleCount + dayCount);
        },
    );

    it.each([PINNED_FULL_WIDTH, 700, 500, 332, 250, PINNED_MIN_WIDTH])(
        'largeur %i : le conteneur porte les data-hide-* et la largeur de la disposition',
        (width) => {
            const html = render({ rows: [row()] }, width);
            const layout = computePinnedLayout(width);
            const zone = /<div\b[^>]*--yt-pinned-w[^>]*>/.exec(html)?.[0] ?? '';

            expect(zone).toContain(`--yt-pinned-w:${layout.width}px`);
            expect(zone).toContain(`--yt-w-listing:${layout.listingWidth}px`);
            for (const col of HIDE_ORDER) {
                expect(zone.includes(`data-hide-${col}=""`), `${col} à ${width} px`).toBe(!layout.visible[col]);
            }
            expect(zone.includes('data-name-detail=""')).toBe(layout.showNameDetail);
        },
    );
});

describe('YieldTable — nom du logement', () => {
    function listingCell(html: string, rowIndex = 0): string {
        return cells(tableRows(html)[rowIndex + 1])[1].html;
    }

    /** Les `<span …>texte</span>` de la cellule, avec leurs classes. */
    function spans(cellHtml: string): { classes: Set<string>; text: string }[] {
        return [...cellHtml.matchAll(/<span\b([^>]*)>([^<]*)<\/span>/g)].map((m) => ({
            classes: new Set((/\bclass="([^"]*)"/.exec(m[1])?.[1] ?? '').split(/\s+/)),
            text: m[2],
        }));
    }

    it('ligne principale = nom interne ; nom complet dessous, masqué hors data-name-detail', () => {
        const cell = listingCell(render({ rows: [row({ internal_name: 'Artistes' })] }));
        const [primary, secondary] = spans(cell);

        expect(primary.text).toBe('Artistes');
        expect(primary.classes.has('font-medium')).toBe(true);

        expect(secondary.text).toBe('Villa des Artistes — Hammamet Nord');
        expect(secondary.classes.has('hidden')).toBe(true);
        expect(secondary.classes.has('group-data-[name-detail]/yt:block')).toBe(true);
    });

    it('sans nom interne : le nom complet en ligne principale, et rien dessous', () => {
        const cell = listingCell(render({ rows: [row({ internal_name: null })] }));
        const texts = spans(cell).map((span) => span.text);

        expect(texts[0]).toBe('Villa des Artistes — Hammamet Nord');
        expect(texts.filter((text) => text.includes('Villa des Artistes'))).toHaveLength(1);
    });

    it("libellé partagé : l'identifiant s'affiche sur chaque ligne, À TOUTE LARGEUR (jamais masqué)", () => {
        const rows = [
            row({ uid: '3360312', name: 'Sunset 203 | Luxury Apt + Gym Lac 1 Tunis', internal_name: 'Sunset 203' }),
            row({ uid: '3360305', name: 'Sunset 204 | Luxury 2BR + Gym Tunisia', internal_name: 'Sunset 203' }),
            row({ uid: '3347542', name: 'Rd Zephyr Marsa Corniche 2 bedrooms', internal_name: null }),
            row({ uid: '4948034', name: 'Rd Zephyr Marsa Corniche 2 bedrooms', internal_name: null }),
        ];

        for (const width of [PINNED_FULL_WIDTH, 400, PINNED_MIN_WIDTH]) {
            const html = render({ rows }, width);
            rows.forEach((r, i) => {
                const detail = spans(listingCell(html, i)).find((span) => span.text.startsWith(r.uid));
                expect(detail, `${r.uid} à ${width} px`).toBeDefined();
                expect(detail!.classes.has('hidden'), `${r.uid} à ${width} px`).toBe(false);
            });
        }
    });
});

describe('YieldTable — ancres de la visite guidée', () => {
    const rows = [row({ uid: 'first' }), row({ uid: 'second' })];

    function anchorsIn(html: string) {
        const [head, first] = tableRows(html);
        return {
            head,
            first,
            count: (anchor: string) => countOf(html, `data-tour="${anchor}"`),
        };
    }

    it('pleine largeur : une seule ancre de chaque, ligne sur la PREMIÈRE ligne, bande sur l’en-tête', () => {
        const { head, first, count } = anchorsIn(render({ rows }));
        for (const anchor of ['yield-row-switch', 'yield-row-push', 'yield-band', 'yield-calendar', 'yield-resize']) {
            expect(count(anchor), anchor).toBe(1);
        }
        expect(first).toContain('data-tour="yield-row-switch"');
        expect(first).toContain('data-tour="yield-row-push"');
        expect(head).toContain('data-tour="yield-band"');
        expect(head).toContain('data-tour="yield-calendar"');
    });

    it('colonne yield masquée : ni ancre de ligne ni ancre de bande (elles mesureraient 0 × 0)', () => {
        const { count } = anchorsIn(render({ rows }, 331));
        expect(count('yield-row-switch')).toBe(0);
        expect(count('yield-row-push')).toBe(0);
        expect(count('yield-band')).toBe(0);
        expect(count('yield-calendar')).toBe(1);
        expect(count('yield-resize')).toBe(1);
    });

    it('Min masquée, yield affichée : ancres de ligne oui, ancre de bande non', () => {
        const { count } = anchorsIn(render({ rows }, 332));
        expect(count('yield-row-switch')).toBe(1);
        expect(count('yield-band')).toBe(0);
    });

    it('liste vide : aucune ancre de ligne', () => {
        const { count } = anchorsIn(render({ rows: [] }));
        expect(count('yield-row-switch')).toBe(0);
        expect(count('yield-row-push')).toBe(0);
        expect(count('yield-band')).toBe(1);
    });
});

/*
 * Ce que l'étape « envoi » de la visite affirme, vérifié sur le rendu : le
 * bouton reste ouvrable yield éteint (c'est le chemin vers la restauration),
 * et la flèche de retour n'existe pas sans le droit de modifier.
 */
describe('YieldTable — bouton d’envoi et flèche de retour', () => {
    /** La balise ouvrante du bouton d'envoi : celui qui porte l'avion en papier. */
    const pushButton = (html: string) => /<button\b([^>]*)><svg\b[^>]*\blucide-send\b/.exec(html)?.[1] ?? '';
    const isDisabled = (attrs: string) => /\sdisabled=""/.test(attrs);

    it('yield éteint : le bouton d’envoi reste cliquable (il ouvre le comparatif, où seul l’envoi est bloqué)', () => {
        const attrs = pushButton(render({ rows: [row({ is_active: false })] }));
        expect(attrs).toContain('aria-label="Push maintenant"');
        expect(isDisabled(attrs)).toBe(false);
    });

    it('logement non configuré ou lecture seule : le bouton d’envoi est grisé', () => {
        const unconfigured = pushButton(render({ rows: [row({ id: null, has_config: false, is_active: false })] }));
        expect(unconfigured).toContain('aria-label="Ce logement n’a pas encore de configuration yield."');
        expect(isDisabled(unconfigured)).toBe(true);

        page.permissions = ['yield.view'];
        const readOnly = pushButton(render({ rows: [row()] }));
        expect(readOnly).toContain('aria-label="Lecture seule');
        expect(isDisabled(readOnly)).toBe(true);
    });

    it('la flèche « Revenir aux prix d’avant » : présente avec une photo ET le droit de modifier, absente sinon', () => {
        const restore = 'aria-label="Revenir aux prix d’avant"';
        expect(countOf(render({ rows: [row({ has_backup: true })] }), restore)).toBe(1);
        expect(countOf(render({ rows: [row({ has_backup: false })] }), restore)).toBe(0);

        page.permissions = ['yield.view'];
        expect(countOf(render({ rows: [row({ has_backup: true })] }), restore)).toBe(0);
    });
});
