import { describe, expect, it } from 'vitest';
import type { PushBackupState, PushPreviewNight } from '../types';
import {
    ABSENT,
    buildRestoreSummary,
    buildSummaryFragments,
    deltaTone,
    deriveSummary,
    formatCapturedAt,
    formatDeltaPct,
    formatMoney,
    formatNightDate,
    latestRow,
    normalizeBackup,
    normalizeNights,
    normalizePushPreview,
    normalizeSummary,
    pushOpenBlockedReason,
    pushSendBlockedReason,
    readFlash,
    restoreSubmitBlocked,
    toFiniteOrNull,
    type RestoreLabels,
    type SummaryLabels,
} from './push-preview';

/** Gabarits français réels — ce sont eux qui composent la phrase de tête. */
const LABELS: SummaryLabels = {
    nights: ':count nuits',
    range: 'de :min à :max',
    booked: ':count nuits réservées ne bougent pas',
    average: ':pct en moyenne',
    withoutCurrent: ':count nuits sans prix actuel',
};

function nuit(partial: Partial<PushPreviewNight> = {}): PushPreviewNight {
    return { date: '2026-09-03', current: 50, proposed: 62, delta_pct: 24, is_booked: false, ...partial };
}

describe('toFiniteOrNull', () => {
    it('accepte un nombre fini et une chaîne numérique', () => {
        expect(toFiniteOrNull(42)).toBe(42);
        expect(toFiniteOrNull(0)).toBe(0);
        expect(toFiniteOrNull('12.5')).toBe(12.5);
    });

    it('refuse tout ce qui ne se lit pas comme un nombre', () => {
        expect(toFiniteOrNull(null)).toBeNull();
        expect(toFiniteOrNull(undefined)).toBeNull();
        expect(toFiniteOrNull('')).toBeNull();
        expect(toFiniteOrNull('  ')).toBeNull();
        expect(toFiniteOrNull('abc')).toBeNull();
        expect(toFiniteOrNull(Number.NaN)).toBeNull();
        expect(toFiniteOrNull(Number.POSITIVE_INFINITY)).toBeNull();
        expect(toFiniteOrNull({})).toBeNull();
    });
});

describe('normalizeNights', () => {
    it('lit une nuit complète du contrat', () => {
        expect(normalizeNights([{ date: '2026-09-03', current: 50, proposed: 62, delta_pct: 24, is_booked: false }])).toEqual([
            { date: '2026-09-03', current: 50, proposed: 62, delta_pct: 24, is_booked: false },
        ]);
    });

    it('garde `current` à null au lieu de le ramener à 0', () => {
        const [n] = normalizeNights([{ date: '2026-09-03', current: null, proposed: 62, delta_pct: null, is_booked: false }]);
        expect(n.current).toBeNull();
        expect(n.delta_pct).toBeNull();
    });

    it('jette les entrées sans date ni prix proposé exploitables', () => {
        expect(
            normalizeNights([
                { date: '', proposed: 62 },
                { date: '2026-09-04' },
                { date: '2026-09-05', proposed: 'nope' },
                null,
                'texte',
                { date: '2026-09-06', proposed: 80 },
            ]),
        ).toEqual([{ date: '2026-09-06', current: null, proposed: 80, delta_pct: null, is_booked: false }]);
    });

    it('rend un tableau vide sur un corps inattendu', () => {
        expect(normalizeNights(undefined)).toEqual([]);
        expect(normalizeNights({})).toEqual([]);
        expect(normalizeNights('<html>')).toEqual([]);
    });

    it("n'estime réservée qu'une nuit explicitement marquée", () => {
        expect(normalizeNights([{ date: '2026-09-03', proposed: 62, is_booked: 'true' }])[0].is_booked).toBe(false);
        expect(normalizeNights([{ date: '2026-09-03', proposed: 62, is_booked: true }])[0].is_booked).toBe(true);
    });
});

describe('deriveSummary', () => {
    it('ignore les nuits réservées dans le compte, la fourchette et la moyenne', () => {
        const resume = deriveSummary(
            [nuit({ proposed: 42, delta_pct: 10 }), nuit({ proposed: 180, delta_pct: 20 }), nuit({ proposed: 999, delta_pct: 400, is_booked: true })],
            'USD',
        );

        expect(resume.count).toBe(2);
        expect(resume.booked_skipped).toBe(1);
        expect(resume.min_proposed).toBe(42);
        expect(resume.max_proposed).toBe(180);
        expect(resume.avg_delta_pct).toBe(15);
    });

    it('compte les nuits sans prix actuel', () => {
        const resume = deriveSummary([nuit({ current: null, delta_pct: null }), nuit({ current: 50 })], 'USD');
        expect(resume.nights_without_current_price).toBe(1);
    });

    it('rend une fourchette et une moyenne nulles quand il n’y a rien à envoyer', () => {
        const resume = deriveSummary([nuit({ is_booked: true })], 'USD');
        expect(resume.count).toBe(0);
        expect(resume.min_proposed).toBeNull();
        expect(resume.max_proposed).toBeNull();
        expect(resume.avg_delta_pct).toBeNull();
    });
});

describe('normalizeSummary', () => {
    it('fait foi du résumé serveur quand il est complet', () => {
        const resume = normalizeSummary(
            {
                count: 87,
                booked_skipped: 3,
                min_proposed: 42,
                max_proposed: 180,
                avg_delta_pct: 12,
                currency: 'USD',
                nights_without_current_price: 0,
            },
            [nuit()],
        );

        expect(resume).toEqual({
            count: 87,
            booked_skipped: 3,
            min_proposed: 42,
            max_proposed: 180,
            avg_delta_pct: 12,
            currency: 'USD',
            nights_without_current_price: 0,
        });
    });

    it('recalcule chaque champ manquant à partir des nuits', () => {
        const nuits = [nuit({ proposed: 42, delta_pct: 10 }), nuit({ proposed: 180, delta_pct: 20, is_booked: true })];
        const resume = normalizeSummary({ currency: 'USD' }, nuits);

        expect(resume.count).toBe(1);
        expect(resume.booked_skipped).toBe(1);
        expect(resume.min_proposed).toBe(42);
        expect(resume.max_proposed).toBe(42);
        expect(resume.avg_delta_pct).toBe(10);
    });

    it('retombe sur USD quand la devise est absente', () => {
        expect(normalizeSummary(null, []).currency).toBe('USD');
        expect(normalizeSummary({ currency: '  ' }, []).currency).toBe('USD');
    });
});

describe('normalizeBackup', () => {
    it('lit une photo existante', () => {
        expect(normalizeBackup({ exists: true, captured_at: '2026-09-02T14:31:00Z', nights: 366 })).toEqual({
            exists: true,
            captured_at: '2026-09-02T14:31:00Z',
            nights: 366,
            // Un serveur plus ancien n'envoie pas la fenêtre : valeurs neutres,
            // jamais une restauration inventée.
            restore_outcome: null,
            restore_from: null,
            restore_until: null,
            restore_nights: 0,
            restore_without_origin: 0,
        });
    });

    it('lit la fenêtre de restauration', () => {
        expect(
            normalizeBackup({
                exists: true,
                captured_at: '2026-09-02T14:31:00Z',
                nights: 366,
                restore_outcome: 'restore',
                restore_from: '2026-09-25',
                restore_until: '2026-12-24',
                restore_nights: 91,
                restore_without_origin: '4',
            }),
        ).toMatchObject({
            restore_outcome: 'restore',
            restore_from: '2026-09-25',
            restore_until: '2026-12-24',
            restore_nights: 91,
            restore_without_origin: 4,
        });
    });

    it('neutralise une fenêtre illisible au lieu de l’inventer', () => {
        const brut = normalizeBackup({
            exists: true,
            restore_outcome: 'tout_va_bien',
            restore_from: '',
            restore_until: 42,
            restore_nights: 'beaucoup',
            restore_without_origin: null,
        });
        expect(brut.restore_outcome).toBeNull();
        expect(brut.restore_from).toBeNull();
        expect(brut.restore_until).toBeNull();
        expect(brut.restore_nights).toBe(0);
        expect(brut.restore_without_origin).toBe(0);
    });

    it("n'affirme jamais l'existence d'une photo par défaut", () => {
        expect(normalizeBackup(undefined).exists).toBe(false);
        expect(normalizeBackup({ exists: 'true' }).exists).toBe(false);
        expect(normalizeBackup({}).captured_at).toBeNull();
        expect(normalizeBackup({}).nights).toBe(0);
    });
});

describe('normalizePushPreview', () => {
    it('rend une structure complète même sur un corps vide', () => {
        const apercu = normalizePushPreview(null);
        expect(apercu.nights).toEqual([]);
        expect(apercu.summary.count).toBe(0);
        expect(apercu.summary.currency).toBe('USD');
        expect(apercu.backup.exists).toBe(false);
    });

    it('lit le corps du contrat', () => {
        const apercu = normalizePushPreview({
            nights: [{ date: '2026-09-03', current: 50, proposed: 62, delta_pct: 24, is_booked: false }],
            summary: {
                count: 87,
                booked_skipped: 3,
                min_proposed: 42,
                max_proposed: 180,
                avg_delta_pct: 12,
                currency: 'USD',
                nights_without_current_price: 0,
            },
            backup: { exists: true, captured_at: '2026-09-02T14:31:00Z', nights: 366 },
        });

        expect(apercu.nights).toHaveLength(1);
        expect(apercu.summary.count).toBe(87);
        expect(apercu.backup.nights).toBe(366);
    });
});

describe('deltaTone', () => {
    it('sépare hausse, baisse, inchangé et inconnu', () => {
        expect(deltaTone(24)).toBe('up');
        expect(deltaTone(-8)).toBe('down');
        expect(deltaTone(0)).toBe('flat');
        expect(deltaTone(null)).toBe('unknown');
    });
});

describe('formatMoney', () => {
    it('affiche le montant brut suivi du symbole', () => {
        expect(formatMoney(42, 'USD')).toBe('42 $');
        expect(formatMoney(62.5, 'USD')).toBe('62.5 $');
        expect(formatMoney(62.499, 'USD')).toBe('62.5 $');
        expect(formatMoney(30, 'EUR')).toBe('30 €');
    });

    it('affiche le code de la devise quand aucun symbole n’est connu', () => {
        expect(formatMoney(120, 'TND')).toBe('120 TND');
    });

    it('affiche « — » et jamais « 0 » pour une nuit sans prix', () => {
        expect(formatMoney(null)).toBe(ABSENT);
        expect(formatMoney(0)).toBe('0 $');
    });
});

describe('formatDeltaPct', () => {
    it('signe la hausse, garde le signe de la baisse, marque l’égalité', () => {
        expect(formatDeltaPct(24)).toBe('+24 %');
        expect(formatDeltaPct(-8)).toBe('-8 %');
        expect(formatDeltaPct(0)).toBe('=');
        expect(formatDeltaPct(0.4)).toBe('=');
        expect(formatDeltaPct(null)).toBe(ABSENT);
    });
});

describe('buildSummaryFragments', () => {
    it('compose la phrase de tête complète', () => {
        const fragments = buildSummaryFragments(
            {
                count: 87,
                booked_skipped: 3,
                min_proposed: 42,
                max_proposed: 180,
                avg_delta_pct: 12,
                currency: 'USD',
                nights_without_current_price: 0,
            },
            LABELS,
        );

        expect(fragments.join(' · ')).toBe('87 nuits · de 42 $ à 180 $ · 3 nuits réservées ne bougent pas · +12 % en moyenne');
    });

    it('omet les fragments sans information plutôt que d’afficher des zéros', () => {
        const fragments = buildSummaryFragments(
            {
                count: 0,
                booked_skipped: 0,
                min_proposed: null,
                max_proposed: null,
                avg_delta_pct: null,
                currency: 'USD',
                nights_without_current_price: 0,
            },
            LABELS,
        );

        expect(fragments).toEqual(['0 nuits']);
    });

    it('signale les nuits sans prix actuel', () => {
        const fragments = buildSummaryFragments(
            {
                count: 10,
                booked_skipped: 0,
                min_proposed: 40,
                max_proposed: 40,
                avg_delta_pct: null,
                currency: 'USD',
                nights_without_current_price: 4,
            },
            LABELS,
        );

        expect(fragments).toContain('4 nuits sans prix actuel');
    });
});

describe('ouverture et envoi : deux verrous distincts', () => {
    const LIBELLES = { noConfig: 'pas de configuration', inactive: 'yield éteint', readOnly: 'lecture seule' };
    const pilote = { id: 7, has_config: true, is_active: true };
    const eteint = { ...pilote, is_active: false };

    describe('pushOpenBlockedReason', () => {
        it('laisse ouvrir une ligne pilotée avec le droit d’écriture', () => {
            expect(pushOpenBlockedReason(pilote, true, LIBELLES)).toBeNull();
        });

        it('refuse une ligne sans configuration : il n’y a rien à comparer', () => {
            expect(pushOpenBlockedReason({ id: null, has_config: false, is_active: false }, true, LIBELLES)).toBe('pas de configuration');
            expect(pushOpenBlockedReason({ id: null, has_config: true, is_active: true }, true, LIBELLES)).toBe('pas de configuration');
        });

        it('LAISSE OUVRIR une ligne dont le yield est éteint — c’est le seul chemin vers la restauration', () => {
            expect(pushOpenBlockedReason(eteint, true, LIBELLES)).toBeNull();
        });

        it('refuse l’ouverture sans yield.manage', () => {
            expect(pushOpenBlockedReason(pilote, false, LIBELLES)).toBe('lecture seule');
        });

        it('nomme d’abord ce qui manque sur la ligne, ensuite les droits', () => {
            expect(pushOpenBlockedReason({ id: null, has_config: false, is_active: false }, false, LIBELLES)).toBe('pas de configuration');
        });
    });

    describe('pushSendBlockedReason', () => {
        it('laisse envoyer une ligne pilotée', () => {
            expect(pushSendBlockedReason(pilote, LIBELLES)).toBeNull();
        });

        it('REFUSE d’envoyer quand le yield est éteint', () => {
            expect(pushSendBlockedReason(eteint, LIBELLES)).toBe('yield éteint');
        });

        it('refuse d’envoyer sans configuration', () => {
            expect(pushSendBlockedReason({ id: null, has_config: false, is_active: true }, LIBELLES)).toBe('pas de configuration');
        });
    });

    it('un yield éteint OUVRE mais N’ENVOIE PAS — la sauvegarde reste joignable au moment où l’on en a le plus besoin', () => {
        expect(pushOpenBlockedReason(eteint, true, LIBELLES)).toBeNull();
        expect(pushSendBlockedReason(eteint, LIBELLES)).toBe('yield éteint');
    });
});

describe('formatCapturedAt', () => {
    it('rend une date lisible', () => {
        expect(formatCapturedAt('2026-09-02T14:31:00')).toBe('02/09/2026 14:31');
    });

    it('rend null plutôt qu’« Invalid Date »', () => {
        expect(formatCapturedAt(null)).toBeNull();
        expect(formatCapturedAt('')).toBeNull();
        expect(formatCapturedAt('pas une date')).toBeNull();
    });
});

describe('formatNightDate', () => {
    it('abrège la date d’une nuit', () => {
        expect(formatNightDate('2026-09-03')).toBe('Thu 03 Sep');
    });

    it('rend la chaîne brute si elle est illisible', () => {
        expect(formatNightDate('bientôt')).toBe('bientôt');
    });
});

describe('readFlash', () => {
    it('lit la forme du contrôleur { type, message }', () => {
        expect(readFlash({ flash: { type: 'success', message: '87 nuits envoyées.' } })).toEqual({ type: 'success', message: '87 nuits envoyées.' });
        expect(readFlash({ flash: { type: 'error', message: 'Bande tarifaire absente.' } })).toEqual({
            type: 'error',
            message: 'Bande tarifaire absente.',
        });
    });

    it('lit la forme Laravel { success } / { error }', () => {
        expect(readFlash({ flash: { success: 'Fait.' } })).toEqual({ type: 'success', message: 'Fait.' });
        expect(readFlash({ flash: { error: 'Refusé.' } })).toEqual({ type: 'error', message: 'Refusé.' });
    });

    it('lit un refus comme un ÉCHEC même s’il arrive avec un succès', () => {
        expect(readFlash({ flash: { success: 'Fait.', error: 'Refusé.' } })).toEqual({ type: 'error', message: 'Refusé.' });
    });

    it('traite un type inconnu comme un succès, jamais l’inverse d’un refus explicite', () => {
        expect(readFlash({ flash: { type: 'info', message: 'Note.' } })).toEqual({ type: 'success', message: 'Note.' });
    });

    it('rend null quand il n’y a rien de lisible', () => {
        expect(readFlash(null)).toBeNull();
        expect(readFlash({})).toBeNull();
        expect(readFlash({ flash: null })).toBeNull();
        expect(readFlash({ flash: 'texte' })).toBeNull();
        expect(readFlash({ flash: {} })).toBeNull();
        expect(readFlash({ flash: { type: 'error', message: '   ' } })).toBeNull();
    });
});

/** Gabarits français réels de l'étape « Revenir aux prix d'avant ». */
const RESTORE_LABELS: RestoreLabels = {
    window: ':count nuits, du :from au :until, photo du :date.',
    withoutOrigin: ':count nuits sans prix d’origine.',
    noOrigin: 'Aucun prix d’origine.',
    nothingPushed: 'Rien écrit depuis la photo.',
    windowPast: 'Tout est passé.',
    unknownDate: 'date inconnue',
};

function sauvegarde(partial: Partial<PushBackupState> = {}): PushBackupState {
    return {
        exists: true,
        captured_at: '2026-09-02T14:31:00',
        nights: 366,
        restore_outcome: 'restore',
        restore_from: '2026-09-25',
        restore_until: '2026-12-24',
        restore_nights: 91,
        restore_without_origin: 0,
        ...partial,
    };
}

describe('buildRestoreSummary', () => {
    it('annonce N nuits, la fenêtre et la date de la photo', () => {
        expect(buildRestoreSummary(sauvegarde(), RESTORE_LABELS)).toEqual(['91 nuits, du 25/09/2026 au 24/12/2026, photo du 02/09/2026 14:31.']);
    });

    it('ajoute les nuits sans prix d’origine quand il y en a (logement 3998836 : 6 rendues, 85 figées)', () => {
        expect(buildRestoreSummary(sauvegarde({ restore_nights: 6, restore_without_origin: 85 }), RESTORE_LABELS)).toEqual([
            '6 nuits, du 25/09/2026 au 24/12/2026, photo du 02/09/2026 14:31.',
            '85 nuits sans prix d’origine.',
        ]);
    });

    it('dit pourquoi rien ne sera réécrit, issue par issue', () => {
        expect(buildRestoreSummary(sauvegarde({ restore_outcome: 'no_origin', restore_nights: 0 }), RESTORE_LABELS)).toEqual([
            'Aucun prix d’origine.',
        ]);
        expect(buildRestoreSummary(sauvegarde({ restore_outcome: 'nothing_pushed', restore_nights: 0 }), RESTORE_LABELS)).toEqual([
            'Rien écrit depuis la photo.',
        ]);
        expect(buildRestoreSummary(sauvegarde({ restore_outcome: 'window_past', restore_nights: 0 }), RESTORE_LABELS)).toEqual(['Tout est passé.']);
    });

    it('retombe sur « date inconnue » plutôt que « Invalid Date »', () => {
        expect(buildRestoreSummary(sauvegarde({ captured_at: null, restore_until: 'n/a' }), RESTORE_LABELS)).toEqual([
            '91 nuits, du 25/09/2026 au date inconnue, photo du date inconnue.',
        ]);
    });

    it('ne dit rien sans aperçu, sans photo ou sans issue connue', () => {
        expect(buildRestoreSummary(null, RESTORE_LABELS)).toEqual([]);
        expect(buildRestoreSummary(sauvegarde({ exists: false }), RESTORE_LABELS)).toEqual([]);
        expect(buildRestoreSummary(sauvegarde({ restore_outcome: null }), RESTORE_LABELS)).toEqual([]);
    });
});

describe('restoreSubmitBlocked', () => {
    it('n’éteint le bouton que le temps d’un geste en cours', () => {
        expect(restoreSubmitBlocked({ sending: true, loading: false, previewError: null })).toBe(true);
        expect(restoreSubmitBlocked({ sending: false, loading: true, previewError: null })).toBe(true);
        expect(restoreSubmitBlocked({ sending: false, loading: false, previewError: null })).toBe(false);
    });

    it('laisse la restauration possible quand l’aperçu a échoué : le serveur revalide', () => {
        // La marche arrière ne dépend jamais du comparatif : un aperçu en
        // échec s'affiche, il n'éteint rien.
        expect(restoreSubmitBlocked({ sending: false, loading: false, previewError: 'Comparatif impossible : RU muet' })).toBe(false);
    });
});

describe('latestRow', () => {
    const ligne = (uid: string, is_active: boolean) => ({ uid, is_active });

    it('relit la ligne à jour par son uid : une restauration en échec a éteint le yield', () => {
        const copie = ligne('3998836', true);
        const rows = [ligne('3884000', true), ligne('3998836', false)];

        expect(latestRow(rows, copie)).toEqual(ligne('3998836', false));
    });

    it('garde la copie si la ligne a disparu, et rend null sans ligne', () => {
        const copie = ligne('3998836', true);

        expect(latestRow([ligne('3884000', true)], copie)).toBe(copie);
        expect(latestRow([ligne('3998836', false)], null)).toBeNull();
    });
});
