import { describe, expect, it } from 'vitest';
import type { ObaRule } from '../types';
import {
    addThreshold,
    focusLeavesCard,
    isFloorThreshold,
    isMonotone,
    newWindow,
    nextThreshold,
    OBA_FLOOR_MULT,
    OBA_WINDOW_MAX_DAY,
    obaRuleIssues,
    occFromField,
    occToField,
    patchThreshold,
    removeThreshold,
    sortThresholds,
} from './oba-rules';
import { RULE_BOUNDS } from './rule-units';

/**
 * Le preset recommandé, fenêtre 16-30, dans sa forme LONGUE (lignes ×1 sans
 * effet comprises, avant leur fusion dans YieldConfig::defaultObaRules) : une
 * table à plusieurs lignes, finissant à 100 %, comme en produit l'éditeur
 * quand on ajoute des seuils — celle dont ces tests ont besoin.
 */
const RECOMMENDED_16_30: ObaRule = {
    window: [16, 30],
    thresholds: [
        { occ: 0, mult: 1 },
        { occ: 10, mult: 1 },
        { occ: 30, mult: 1 },
        { occ: 50, mult: 1 },
        { occ: 60, mult: 1 },
        { occ: 70, mult: 1 },
        { occ: 80, mult: 1.05 },
        { occ: 100, mult: 1.1 },
    ],
};

const rule = (...thresholds: Array<[number, number]>): ObaRule => ({ window: [0, 15], thresholds: thresholds.map(([occ, mult]) => ({ occ, mult })) });

describe('newWindow — une échéance ajoutée', () => {
    it('suit la dernière échéance, sur 15 jours, avec sa seule ligne « Dès 0 % → ×1 »', () => {
        expect(newWindow([{ window: [0, 15], thresholds: [] }, RECOMMENDED_16_30])).toEqual({ window: [31, 45], thresholds: [{ occ: 0, mult: 1 }] });
    });

    it('part de J0 quand il n’y a aucune échéance', () => {
        expect(newWindow([])).toEqual({ window: [0, 14], thresholds: [{ occ: 0, mult: 1 }] });
    });

    it('suit la fin la plus LOINTAINE, même si les échéances ne sont pas dans l’ordre', () => {
        expect(newWindow([RECOMMENDED_16_30, { window: [0, 15], thresholds: [] }])?.window).toEqual([31, 45]);
    });

    it('ne dépasse jamais le jour 365 (le max:365 du serveur), et il n’y en a plus quand la dernière y finit', () => {
        expect(OBA_WINDOW_MAX_DAY).toBe(365);
        expect(newWindow([{ window: [0, 355], thresholds: [] }])?.window).toEqual([356, 365]);
        expect(newWindow([{ window: [0, 364], thresholds: [] }])?.window).toEqual([365, 365]);
        expect(newWindow([{ window: [0, 365], thresholds: [] }])).toBeNull();
        expect(newWindow([{ window: [300, 400], thresholds: [] }])).toBeNull();
    });
});

describe('nextThreshold / addThreshold — un seuil ajouté', () => {
    it('10 points au-dessus du dernier, au même multiplicateur', () => {
        expect(nextThreshold(rule([0, 1], [40, 1.1]))).toEqual({ occ: 50, mult: 1.1 });
    });

    it('plafonné à 100 %', () => {
        expect(nextThreshold(rule([0, 1], [95, 1.2]))).toEqual({ occ: 100, mult: 1.2 });
    });

    it('le dernier seuil est à 100 % (comme dans les presets) : il se place au-dessus du précédent', () => {
        expect(nextThreshold(RECOMMENDED_16_30)).toEqual({ occ: 90, mult: 1.05 });
    });

    it('au point près quand chaque pas de 10 est pris, et null quand tout est pris', () => {
        const tens = { window: [0, 15] as [number, number], thresholds: Array.from({ length: 11 }, (_, i) => ({ occ: i * 10, mult: 1 })) };
        expect(nextThreshold(tens)).toEqual({ occ: 91, mult: 1 });

        const full = { window: [0, 15] as [number, number], thresholds: Array.from({ length: 101 }, (_, i) => ({ occ: i, mult: 1 })) };
        expect(nextThreshold(full)).toBeNull();
        expect(addThreshold(full)).toBe(full);
    });

    it('prend le multiplicateur du seuil JUSTE EN DESSOUS du taux proposé : la courbe ne baisse pas', () => {
        // 100 % puis 90 % sont pris : 85 + 10 = 95 %, rangé entre 90 % (×1,1) et 100 % (×1,2).
        const table = rule([0, 1], [85, 1.05], [90, 1.1], [100, 1.2]);
        expect(nextThreshold(table)).toEqual({ occ: 95, mult: 1.1 });

        const added = addThreshold(table);
        expect(added.thresholds.map((t) => t.occ)).toEqual([0, 85, 90, 95, 100]);
        expect(isMonotone(added)).toBe(true);
        expect(obaRuleIssues(added)).toEqual([]);
    });

    it('au point près, le voisin du dessous aussi : 91 % prend le multiplicateur de 90 %', () => {
        const tens = rule(...Array.from({ length: 11 }, (_, i) => [i * 10, 1 + i / 100] as [number, number]));
        expect(nextThreshold(tens)).toEqual({ occ: 91, mult: 1.09 });
        expect(isMonotone(addThreshold(tens))).toBe(true);
    });

    it('une échéance vide reçoit sa ligne « Dès 0 % »', () => {
        expect(nextThreshold({ window: [0, 15], thresholds: [] })).toEqual({ occ: 0, mult: OBA_FLOOR_MULT });
    });

    it('addThreshold range le seuil à sa place — la VALEUR est triée, la courbe reste monotone', () => {
        const added = addThreshold(RECOMMENDED_16_30);
        expect(added.thresholds.map((t) => t.occ)).toEqual([0, 10, 30, 50, 60, 70, 80, 90, 100]);
        expect(added.thresholds[7]).toEqual({ occ: 90, mult: 1.05 });
        expect(isMonotone(added)).toBe(true);
    });
});

describe('sortThresholds — tri de la VALEUR (focus qui quitte la carte, ajout, suppression)', () => {
    it('trie par taux croissant, de façon stable, un taux illisible en dernier', () => {
        const sorted = sortThresholds(rule([50, 1.1], [0, 1], [Number.NaN, 1.3], [30, 1.05], [30, 1.06]));
        expect(sorted.thresholds.map((t) => t.mult)).toEqual([1, 1.05, 1.06, 1.1, 1.3]);
    });

    it('rend la MÊME table quand elle est déjà triée : quitter la carte ne change pas la valeur', () => {
        const sorted = rule([0, 1], [50, 1.1]);
        expect(sortThresholds(sorted)).toBe(sorted);
    });

    it('après tri, modifier la ligne i modifie le seuil AFFICHÉ à la ligne i (index de la valeur)', () => {
        // L'utilisateur a tapé 20 dans la dernière ligne, puis quitte la carte.
        const typed = rule([0, 1], [50, 1.1], [20, 1.05]);
        const sorted = sortThresholds(typed);
        expect(sorted.thresholds.map((t) => t.occ)).toEqual([0, 20, 50]);

        // Il modifie ensuite le multiplicateur de la 2e ligne affichée (20 %).
        const patched = patchThreshold(sorted, 1, { mult: 1.08 });
        expect(patched.thresholds[1]).toEqual({ occ: 20, mult: 1.08 });
        expect(patched.thresholds[2]).toEqual({ occ: 50, mult: 1.1 });

        // Et supprime la 3e ligne affichée (50 %) : c'est bien elle qui part.
        expect(removeThreshold(patched, 2).thresholds.map((t) => t.occ)).toEqual([0, 20]);
    });
});

describe('removeThreshold — la suppression termine le geste : le reste est trié', () => {
    it('retire la ligne VUE à cet index, puis range la ligne restée hors de sa place', () => {
        // Le 60 % vient d'être retapé 5 (focus encore dans la carte : pas trié).
        const typed = rule([0, 1], [10, 1], [30, 1], [50, 1], [5, 1], [100, 1.05]);
        expect(removeThreshold(typed, 3).thresholds.map((t) => t.occ)).toEqual([0, 5, 10, 30, 100]);
        expect(removeThreshold(typed, 4).thresholds.map((t) => t.occ)).toEqual([0, 10, 30, 50, 100]);
    });
});

describe('ligne « Dès 0 % » verrouillée', () => {
    it('est la PREMIÈRE ligne à 0 % — un second 0 % tapé ailleurs reste modifiable', () => {
        const table = rule([0, 1], [50, 1.1], [0, 1.2]);
        expect(isFloorThreshold(table, 0)).toBe(true);
        expect(isFloorThreshold(table, 1)).toBe(false);
        expect(isFloorThreshold(table, 2)).toBe(false);
    });

    it('ne se supprime pas ; les autres lignes, si', () => {
        const table = rule([0, 1], [50, 1.1]);
        expect(removeThreshold(table, 0)).toBe(table);
        expect(removeThreshold(table, 1).thresholds).toEqual([{ occ: 0, mult: 1 }]);
    });

    it('une table sans ligne 0 % n’a pas de ligne verrouillée', () => {
        const table = rule([10, 1], [50, 1.1]);
        expect(isFloorThreshold(table, 0)).toBe(false);
        expect(removeThreshold(table, 0).thresholds).toEqual([{ occ: 50, mult: 1.1 }]);
    });
});

describe('isMonotone / obaRuleIssues — ce que le serveur refusera', () => {
    it('une courbe qui ne baisse pas, même non triée, est monotone', () => {
        expect(isMonotone(RECOMMENDED_16_30)).toBe(true);
        expect(isMonotone(rule([80, 1.1], [0, 1], [40, 1.05]))).toBe(true);
        expect(obaRuleIssues(RECOMMENDED_16_30)).toEqual([]);
    });

    it('une courbe qui baisse ne l’est pas', () => {
        expect(isMonotone(rule([0, 1.2], [50, 1.1]))).toBe(false);
        expect(obaRuleIssues(rule([0, 1.2], [50, 1.1]))).toEqual(['not_monotone']);
    });

    it('l’ancien preset recommandé cumule : ligne 0 % manquante, remise sous ×1', () => {
        expect(obaRuleIssues(rule([10, 0.85], [30, 0.9], [50, 0.95], [60, 1], [100, 1]))).toEqual(['floor_missing', 'below_floor']);
    });

    it('deux seuils au même taux', () => {
        expect(obaRuleIssues(rule([0, 1], [30, 1.1], [30, 1.1]))).toEqual(['duplicate_occ']);
    });

    it('deux champs de taux vidés ne sont pas « le même taux »', () => {
        expect(obaRuleIssues(rule([0, 1], [Number.NaN, 1.1], [Number.NaN, 1.2]))).toEqual([]);
    });

    it('le plancher de l’avertissement EST le min du champ multiplicateur (une seule source)', () => {
        expect(OBA_FLOOR_MULT).toBe(RULE_BOUNDS.obaMult.min);
        expect(OBA_FLOOR_MULT).toBe(1);
    });
});

describe('occFromField / occToField — le champ « taux »', () => {
    it('un champ VIDÉ est un taux illisible (NaN), pas un second « 0 % »', () => {
        expect(occFromField('')).toBeNaN();
        expect(occFromField('  ')).toBeNaN();
        expect(occFromField('0')).toBe(0);
        expect(occFromField('35')).toBe(35);
        expect(occFromField('12.5')).toBe(12.5);
    });

    it('le vidé ne crée ni doublon de la ligne 0 %, ni ligne remontée sous elle au tri', () => {
        const emptied = rule([0, 1], [50, 1.1], [80, 1.2]);
        const patched = patchThreshold(emptied, 1, { occ: occFromField('') });
        expect(obaRuleIssues(patched)).not.toContain('duplicate_occ');
        expect(isFloorThreshold(patched, 1)).toBe(false);
        expect(sortThresholds(patched).thresholds.map((t) => t.mult)).toEqual([1, 1.2, 1.1]); // le vidé en dernier
    });

    it('un taux illisible s’affiche vide, jamais « NaN »', () => {
        expect(occToField(Number.NaN)).toBe('');
        expect(occToField(0)).toBe(0);
        expect(occToField(35)).toBe(35);
    });
});

describe('focusLeavesCard — quand trier la valeur', () => {
    const inside = { id: 'multiplicateur de la même ligne' } as unknown as EventTarget;
    const outside = { id: 'bouton Enregistrer' } as unknown as EventTarget;
    const card = { contains: (node: Node | null) => node === (inside as unknown as Node) } as Pick<Node, 'contains'>;

    it('non, quand le focus passe à un autre champ de la carte (Tab vers le multiplicateur)', () => {
        expect(focusLeavesCard(card, inside, false)).toBe(false);
    });

    it('non, quand un clic dans la carte ne donne le focus à rien (bouton sous Safari / Firefox macOS)', () => {
        expect(focusLeavesCard(card, null, true)).toBe(false);
        expect(focusLeavesCard(card, inside, true)).toBe(false);
    });

    it('oui, quand le focus part ailleurs, ou nulle part sans clic dans la carte', () => {
        expect(focusLeavesCard(card, outside, false)).toBe(true);
        expect(focusLeavesCard(card, null, false)).toBe(true);
    });
});
