import { describe, expect, it } from 'vitest';
import { bilanLot, creerLotRunner, etatInitialLot, lignesRestantes, lotExpire, progressionLot, type EtatLot } from './lot-runner';
import type { ResultatDepot } from './types';

const ok = (ref: string): ResultatDepot => ({ ok: true, message: 'Déposé', reference: ref, detail: null });
const ko = (message: string, code: string | null = null): ResultatDepot => ({ ok: false, message, code });

/** Une promesse qu'on résout à la main — pour observer ce qui est en vol. */
function differee<T>() {
    let resoudre!: (valeur: T) => void;
    const promesse = new Promise<T>((r) => {
        resoudre = r;
    });

    return { promesse, resoudre };
}

/** Laisse passer les microtâches en attente. */
const vider = () => new Promise<void>((r) => setTimeout(r, 0));

describe('etatInitialLot', () => {
    it('met toutes les lignes en attente, dans l’ordre, sans doublon', () => {
        const etat = etatInitialLot([3, 1, 3, 2]);
        expect(etat.ordre).toEqual([3, 1, 2]);
        expect(etat.statut).toBe('pret');
        expect(Object.values(etat.lignes).every((l) => l.etat === 'en_attente')).toBe(true);
    });
});

describe('creerLotRunner — ordre et séquence', () => {
    it('dépose les lignes une par une, dans l’ordre de la file', async () => {
        const appels: number[] = [];
        const runner = creerLotRunner([10, 20, 30], async (id) => {
            appels.push(id);
            return ok(`R${id}`);
        });

        const fin = await runner.lancer();

        expect(appels).toEqual([10, 20, 30]);
        expect(fin.statut).toBe('termine');
        expect(fin.lignes[20]).toMatchObject({ etat: 'envoyee', reference: 'R20' });
        expect(progressionLot(fin)).toEqual({ envoyees: 3, total: 3, pourcentage: 100 });
    });

    it('n’a jamais deux appels en vol, même si on relance pendant l’envoi', async () => {
        let enVol = 0;
        let maxEnVol = 0;
        const attentes = new Map<number, ReturnType<typeof differee<ResultatDepot>>>();

        const runner = creerLotRunner([1, 2, 3], (id) => {
            enVol++;
            maxEnVol = Math.max(maxEnVol, enVol);
            const d = differee<ResultatDepot>();
            attentes.set(id, d);
            return d.promesse.finally(() => {
                enVol--;
            });
        });

        const premier = runner.lancer();
        const second = runner.lancer(); // doit être sans effet
        await vider();

        expect(runner.enCours()).toBe(true);
        expect([...attentes.keys()]).toEqual([1]);

        attentes.get(1)!.resoudre(ok('A'));
        await vider();
        expect([...attentes.keys()]).toEqual([1, 2]);

        attentes.get(2)!.resoudre(ok('B'));
        await vider();
        attentes.get(3)!.resoudre(ok('C'));

        await Promise.all([premier, second]);
        expect(maxEnVol).toBe(1);
        expect(runner.etat().statut).toBe('termine');
        expect(runner.enCours()).toBe(false);
    });

    it('publie la ligne « en cours » avant l’appel, puis son résultat', async () => {
        const instantanes: EtatLot[] = [];
        const runner = creerLotRunner(
            [7],
            async () => ok('X'),
            (e) => instantanes.push(e),
        );

        await runner.lancer();

        const etatsDeLaLigne = instantanes.map((e) => e.lignes[7].etat);
        expect(etatsDeLaLigne).toContain('en_cours');
        expect(etatsDeLaLigne.at(-1)).toBe('envoyee');
        // Jamais muté : chaque transition est un nouvel objet.
        expect(new Set(instantanes).size).toBe(instantanes.length);
    });
});

describe('creerLotRunner — arrêt à la première erreur', () => {
    it('s’arrête sur la première erreur et ne touche pas aux lignes suivantes', async () => {
        const appels: number[] = [];
        const runner = creerLotRunner([1, 2, 3], async (id) => {
            appels.push(id);
            return id === 2 ? ko('Refus de la DGI', 'refus') : ok(`R${id}`);
        });

        const etat = await runner.lancer();

        expect(appels).toEqual([1, 2]);
        expect(etat.statut).toBe('arrete');
        expect(etat.erreur).toEqual({ ligneId: 2, message: 'Refus de la DGI', code: 'refus' });
        expect(etat.lignes[1].etat).toBe('envoyee');
        expect(etat.lignes[2]).toMatchObject({ etat: 'erreur', message: 'Refus de la DGI' });
        expect(etat.lignes[3].etat).toBe('en_attente');
        expect(lignesRestantes(etat)).toEqual([2, 3]);
    });

    it('traite une exception du réseau comme une erreur, sans la laisser remonter', async () => {
        const runner = creerLotRunner([1, 2], async (id) => {
            if (id === 1) throw new Error('Réseau coupé');
            return ok('R');
        });

        const etat = await runner.lancer();

        expect(etat.statut).toBe('arrete');
        expect(etat.erreur).toMatchObject({ ligneId: 1, message: 'Réseau coupé', code: null });
        expect(etat.lignes[2].etat).toBe('en_attente');
    });
});

describe('creerLotRunner — reprise', () => {
    it('reprend en retentant d’abord la ligne en échec, puis continue', async () => {
        const appels: number[] = [];
        let refuse = true;
        const runner = creerLotRunner([1, 2, 3], async (id) => {
            appels.push(id);
            if (id === 2 && refuse) {
                refuse = false;
                return ko('Dépôt en cours');
            }
            return ok(`R${id}`);
        });

        await runner.lancer();
        const etat = await runner.reprendre();

        expect(appels).toEqual([1, 2, 2, 3]);
        expect(etat.statut).toBe('termine');
        expect(etat.erreur).toBeNull();
        expect(etat.ordre.every((id) => etat.lignes[id].etat === 'envoyee')).toBe(true);
    });

    it('ne redépose jamais une ligne déjà envoyée', async () => {
        const appels: number[] = [];
        const runner = creerLotRunner([1, 2], async (id) => {
            appels.push(id);
            return appels.filter((x) => x === 2).length === 1 && id === 2 ? ko('Refus') : ok('R');
        });

        await runner.lancer();
        await runner.reprendre();

        expect(appels.filter((id) => id === 1)).toHaveLength(1);
    });

    it('est sans effet sur un lot terminé', async () => {
        const appels: number[] = [];
        const runner = creerLotRunner([1], async (id) => {
            appels.push(id);
            return ok('R');
        });

        await runner.lancer();
        await runner.reprendre();

        expect(appels).toEqual([1]);
    });
});

describe('creerLotRunner — arrêt demandé', () => {
    it('termine la ligne en vol puis s’arrête, sans en lancer d’autre', async () => {
        const appels: number[] = [];
        const d = differee<ResultatDepot>();
        const runner = creerLotRunner([1, 2, 3], (id) => {
            appels.push(id);
            return id === 1 ? d.promesse : Promise.resolve(ok('R'));
        });

        const fin = runner.lancer();
        await vider();
        runner.arreter();
        d.resoudre(ok('R1'));
        const etat = await fin;

        expect(appels).toEqual([1]);
        expect(etat.statut).toBe('arrete');
        expect(etat.erreur).toBeNull();
        expect(etat.lignes[1].etat).toBe('envoyee');
        expect(lignesRestantes(etat)).toEqual([2, 3]);
    });
});

describe('lotExpire', () => {
    const maintenant = Date.parse('2026-10-08T10:00:00Z');

    it('prend le code `lot_expire` du serveur comme signal premier, quelle que soit l’heure', () => {
        expect(lotExpire('2026-10-08T23:00:00Z', { message: 'Refus', code: 'lot_expire' }, maintenant)).toBe(true);
        expect(lotExpire('2026-10-08T23:00:00Z', { message: 'Ligne écartée', code: 'non_deposable' }, maintenant)).toBe(false);
    });

    it('croit le serveur quand son code ou son message parle d’expiration', () => {
        expect(lotExpire(null, { message: 'Refus', code: 'lot_expire' }, maintenant)).toBe(true);
        expect(lotExpire(null, { message: 'Ce lot a expiré, recréez-le.', code: null }, maintenant)).toBe(true);
    });

    it('compare l’échéance à l’horloge', () => {
        expect(lotExpire('2026-10-08T09:59:59Z', null, maintenant)).toBe(true);
        expect(lotExpire('2026-10-08T10:30:00Z', null, maintenant)).toBe(false);
    });

    it('ne conclut pas à l’expiration sur une date illisible ou un refus ordinaire', () => {
        expect(lotExpire('pas une date', null, maintenant)).toBe(false);
        expect(lotExpire('2026-10-08T10:30:00Z', { message: 'Refus de la DGI', code: 'refus' }, maintenant)).toBe(false);
    });
});

describe('creerLotRunner — lignes écartées', () => {
    it('s’arrête sur une ligne `non_deposable`, la marque écartée, et « Reprendre » passe à la suivante sans la retenter', async () => {
        const appels: number[] = [];
        const runner = creerLotRunner([1, 2, 3], async (id) => {
            appels.push(id);
            return id === 2 ? ko('Déjà en attente de la DGI', 'non_deposable') : ok(`R${id}`);
        });

        const arret = await runner.lancer();
        expect(arret.statut).toBe('arrete');
        expect(arret.erreur).toMatchObject({ ligneId: 2, code: 'non_deposable' });
        expect(arret.lignes[2]).toMatchObject({ etat: 'ecartee', message: 'Déjà en attente de la DGI' });
        expect(lignesRestantes(arret)).toEqual([3]);

        const fin = await runner.reprendre();
        expect(appels).toEqual([1, 2, 3]);
        expect(fin.statut).toBe('termine');
        expect(fin.lignes[2].etat).toBe('ecartee');
    });

    it('traite `hors_lot` comme une ligne écartée, et une erreur ordinaire comme une erreur retentée', async () => {
        const runner = creerLotRunner([1], async () => ko('Hors du lot', 'hors_lot'));
        expect((await runner.lancer()).lignes[1].etat).toBe('ecartee');

        const autre = creerLotRunner([1], async () => ko('Dépôt en cours', 'depot_en_cours'));
        expect((await autre.lancer()).lignes[1].etat).toBe('erreur');
    });

    it('« Passer cette ligne » écarte la ligne en erreur et reprend avec les suivantes', async () => {
        const appels: number[] = [];
        const runner = creerLotRunner([1, 2, 3], async (id) => {
            appels.push(id);
            return id === 1 ? ko('Refus de la DGI', 'refus') : ok(`R${id}`);
        });

        await runner.lancer();
        const fin = await runner.passer();

        expect(appels).toEqual([1, 2, 3]);
        expect(fin.statut).toBe('termine');
        expect(fin.lignes[1]).toMatchObject({ etat: 'ecartee', message: 'Refus de la DGI' });
        expect(bilanLot(fin)).toEqual({ envoyees: 2, ecartees: 1, erreurs: 0, enAttente: 0 });
    });

    it('n’écarte rien sans envoi arrêté sur une erreur, ni pendant un envoi', async () => {
        const d = differee<ResultatDepot>();
        const runner = creerLotRunner([1, 2], (id) => (id === 1 ? d.promesse : Promise.resolve(ok('R'))));

        expect(runner.ecarterLigneEnErreur()).toBe(false);

        const fin = runner.lancer();
        await vider();
        expect(runner.ecarterLigneEnErreur()).toBe(false);
        d.resoudre(ok('R1'));
        await fin;

        expect(runner.ecarterLigneEnErreur()).toBe(false);
        expect(runner.etat().statut).toBe('termine');
    });

    it('une ligne écartée ne repart jamais, même après plusieurs reprises', async () => {
        const appels: number[] = [];
        let echecsSur3 = 1;
        const runner = creerLotRunner([1, 2, 3], async (id) => {
            appels.push(id);
            if (id === 1) return ko('Hors du lot', 'hors_lot');
            if (id === 3 && echecsSur3-- > 0) return ko('Dépôt en cours', 'depot_en_cours');
            return ok(`R${id}`);
        });

        await runner.lancer(); // arrêt sur 1 (écartée)
        await runner.reprendre(); // 2 part, arrêt sur 3 (erreur)
        await runner.reprendre(); // 3 retentée, part

        expect(appels).toEqual([1, 2, 3, 3]);
        expect(appels.filter((id) => id === 1)).toHaveLength(1);
        expect(runner.etat().statut).toBe('termine');
    });

    it('« Passer » n’a jamais deux appels en vol', async () => {
        let enVol = 0;
        let maxEnVol = 0;
        const runner = creerLotRunner([1, 2, 3, 4], async (id) => {
            enVol++;
            maxEnVol = Math.max(maxEnVol, enVol);
            await vider();
            enVol--;
            return id === 2 ? ko('Refus', 'refus') : ok(`R${id}`);
        });

        await runner.lancer();
        await Promise.all([runner.passer(), runner.passer(), runner.reprendre()]);

        expect(maxEnVol).toBe(1);
        expect(runner.etat().statut).toBe('termine');
    });

    it('la progression ne compte que les lignes envoyées ; le bilan distingue envoyées, écartées et erreurs', async () => {
        const runner = creerLotRunner([1, 2, 3, 4], async (id) => {
            if (id === 2) return ko('Hors du lot', 'hors_lot');
            if (id === 4) return ko('Refus', 'refus');
            return ok(`R${id}`);
        });

        await runner.lancer(); // 1 part, arrêt sur 2 (écartée)
        const etat = await runner.reprendre(); // 3 part, arrêt sur 4 (erreur)

        expect(progressionLot(etat)).toEqual({ envoyees: 2, total: 4, pourcentage: 50 });
        expect(bilanLot(etat)).toEqual({ envoyees: 2, ecartees: 1, erreurs: 1, enAttente: 0 });
    });
});
