import type { RuleTemplate } from '@/types/automatisations';
import { describe, expect, it } from 'vitest';
import {
    dateAndTime,
    describeTrigger,
    fill,
    filterProperties,
    formatDateTimeShort,
    formatDelay,
    groupTemplates,
    insertAtCursor,
    isUnknownResult,
    lastRunState,
    linkParts,
    linksBaseMismatch,
    sendDelayMinutes,
    sendTimeline,
    statusTone,
    tokensIn,
    unknownTokens,
    usesOffset,
    usesSendTime,
    type TriggerParts,
} from './automatisations-format';

const rule = (parts: Partial<TriggerParts>): TriggerParts => ({
    trigger: 'before_checkin',
    offsetDays: 1,
    sendTime: '10:00',
    delayMinutes: 0,
    ...parts,
});

describe('linksBaseMismatch — APP_URL contre l’adresse du site', () => {
    it('ne dit rien quand les deux origines concordent (chemin et barre finale ignorés)', () => {
        expect(linksBaseMismatch('https://manager.thelandlord.tn/', 'https://manager.thelandlord.tn')).toBeNull();
        expect(linksBaseMismatch('https://manager.thelandlord.tn/app', 'https://manager.thelandlord.tn')).toBeNull();
    });

    it('rend la base des liens quand elle diffère — cas de l’APP_URL local laissé en production', () => {
        expect(linksBaseMismatch('http://127.0.0.1:8000/', 'https://manager.thelandlord.tn')).toBe('http://127.0.0.1:8000');
        expect(linksBaseMismatch('http://manager.thelandlord.tn', 'https://manager.thelandlord.tn')).toBe('http://manager.thelandlord.tn');
    });

    it('se tait quand on ne peut pas comparer', () => {
        expect(linksBaseMismatch(null, 'https://manager.thelandlord.tn')).toBeNull();
        expect(linksBaseMismatch('pas une url', 'https://manager.thelandlord.tn')).toBeNull();
    });
});

describe('filterProperties — recherche par nom ou nom interne', () => {
    const properties = [
        { id: 1, name: '2BR Apt · Parking · Central La Marsa', internalName: 'T66' },
        { id: 2, name: '1BR Apt | Balcony · Luxury Résidence · La Marsa', internalName: 'LM-07' },
        { id: 3, name: 'Villa Sidi Bou', internalName: null },
    ];
    const ids = (search: string) => filterProperties(properties, search).map((property) => property.id);

    it('sans recherche, tout reste', () => {
        expect(ids('')).toEqual([1, 2, 3]);
        expect(ids('   ')).toEqual([1, 2, 3]);
    });

    it('trouve par nom interne, casse ignorée', () => {
        expect(ids('t66')).toEqual([1]);
        expect(ids('LM-07')).toEqual([2]);
        expect(ids('lm07')).toEqual([2]); // séparateur omis
    });

    it('trouve toujours par nom, accents et ordre des mots ignorés', () => {
        expect(ids('residence balcony')).toEqual([2]);
        expect(ids('sidi')).toEqual([3]);
        expect(ids('la marsa')).toEqual([1, 2]);
    });

    it('un logement sans nom interne ne casse pas la recherche', () => {
        expect(ids('zzz')).toEqual([]);
    });
});

describe('détail d’un envoi — déroulé, retard, liens', () => {
    const local = (y: number, m: number, d: number, h: number, min: number) => new Date(y, m - 1, d, h, min).toISOString();
    const base = {
        scheduledAt: local(2026, 9, 28, 15, 0),
        createdAt: local(2026, 9, 28, 15, 0),
        sentAt: null as string | null,
        updatedAt: local(2026, 9, 28, 15, 1),
    };

    it('envoyé : prévu → préparé → envoyé, en vert, à l’heure d’envoi', () => {
        const steps = sendTimeline({ ...base, status: 'sent', sentAt: local(2026, 9, 28, 15, 1) });
        expect(steps.map((step) => step.kind)).toEqual(['scheduled', 'prepared', 'sent']);
        expect(steps[2]).toEqual({ kind: 'sent', at: local(2026, 9, 28, 15, 1), tone: 'green' });
        expect(steps[0].tone).toBeNull();
    });

    it('chaque fin a sa couleur ; une étape en cours n’a pas d’heure', () => {
        expect(sendTimeline({ ...base, status: 'failed' }).at(-1)).toEqual({ kind: 'failed', at: base.updatedAt, tone: 'red' });
        expect(sendTimeline({ ...base, status: 'skipped' }).at(-1)?.tone).toBe('gray');
        expect(sendTimeline({ ...base, status: 'cancelled' }).at(-1)?.kind).toBe('cancelled');
        expect(sendTimeline({ ...base, status: 'retry' }).at(-1)?.tone).toBe('amber');
        expect(sendTimeline({ ...base, status: 'pending_approval' }).at(-1)).toEqual({ kind: 'pending', at: null, tone: 'amber' });
        expect(sendTimeline({ ...base, status: 'waiting_channel' }).at(-1)).toEqual({ kind: 'waiting', at: null, tone: 'amber' });
    });

    it('sans heure prévue ni date de préparation, seule la fin reste', () => {
        expect(sendTimeline({ ...base, scheduledAt: null, createdAt: null, status: 'skipped' }).map((step) => step.kind)).toEqual(['skipped']);
    });

    it('retard : minutes entre l’heure prévue et l’envoi, jamais négatif', () => {
        expect(sendDelayMinutes({ status: 'sent', scheduledAt: base.scheduledAt, sentAt: local(2026, 9, 28, 15, 1) })).toBe(1);
        expect(sendDelayMinutes({ status: 'sent', scheduledAt: base.scheduledAt, sentAt: local(2026, 9, 28, 16, 5) })).toBe(65);
        expect(sendDelayMinutes({ status: 'sent', scheduledAt: base.scheduledAt, sentAt: local(2026, 9, 28, 14, 59) })).toBe(0);
        expect(sendDelayMinutes({ status: 'failed', scheduledAt: base.scheduledAt, sentAt: null })).toBeNull();
    });

    it('date et heure séparées, en jj/mm/aaaa et HH:MM', () => {
        expect(dateAndTime(local(2026, 9, 28, 15, 1))).toEqual({ date: '28/09/2026', time: '15:01' });
        expect(dateAndTime(null)).toBeNull();
        expect(dateAndTime('illisible')).toBeNull();
    });

    it('adresses web cliquables, ponctuation finale laissée au texte', () => {
        expect(linkParts('Adresse : https://maps.app.goo.gl/qMi4W5. Merci')).toEqual([
            { text: 'Adresse : ', href: null },
            { text: 'https://maps.app.goo.gl/qMi4W5', href: 'https://maps.app.goo.gl/qMi4W5' },
            { text: '. Merci', href: null },
        ]);
        expect(linkParts('Sans lien')).toEqual([{ text: 'Sans lien', href: null }]);
        expect(linkParts('(https://a.tn/x)')).toEqual([
            { text: '(', href: null },
            { text: 'https://a.tn/x', href: 'https://a.tn/x' },
            { text: ')', href: null },
        ]);
    });
});

describe('groupTemplates — la galerie des modèles', () => {
    const tpl = (key: string, group: 'rentals_united' | 'landlord'): RuleTemplate => ({
        key,
        group,
        replaces: group === 'rentals_united' ? 'Before checkout' : null,
        name: key,
        description: '',
        trigger: 'before_checkout',
        offsetDays: 1,
        sendTime: '12:00',
        delayMinutes: 0,
        bodies: { fr: 'x', en: 'x' },
        emailSubjects: { fr: 'x', en: 'x' },
    });

    it('Rentals United d’abord, quel que soit l’ordre reçu ; ordre conservé dans chaque groupe', () => {
        const sections = groupTemplates([tpl('a', 'landlord'), tpl('ru1', 'rentals_united'), tpl('b', 'landlord'), tpl('ru2', 'rentals_united')]);

        expect(sections.map((section) => section.group)).toEqual(['rentals_united', 'landlord']);
        expect(sections[0].templates.map((template) => template.key)).toEqual(['ru1', 'ru2']);
        expect(sections[1].templates.map((template) => template.key)).toEqual(['a', 'b']);
    });

    it('un groupe vide n’affiche pas de titre', () => {
        expect(groupTemplates([tpl('a', 'landlord')]).map((section) => section.group)).toEqual(['landlord']);
        expect(groupTemplates([])).toEqual([]);
    });
});

describe('describeTrigger — le moment d’envoi en une phrase', () => {
    it.each([
        [rule({ trigger: 'before_checkin', offsetDays: 2 }), "2 jours avant l'arrivée à 10:00"],
        [rule({ trigger: 'before_checkin', offsetDays: 1 }), "1 jour avant l'arrivée à 10:00"],
        [rule({ trigger: 'checkin_day', offsetDays: 0, sendTime: '14:00' }), "Le jour de l'arrivée à 14:00"],
        [rule({ trigger: 'during_stay', offsetDays: 2 }), "2 jours après l'arrivée à 10:00"],
        [rule({ trigger: 'before_checkout', offsetDays: 1, sendTime: '18:00' }), '1 jour avant le départ à 18:00'],
        [rule({ trigger: 'checkout_day', offsetDays: 0, sendTime: '09:00' }), 'Le jour du départ à 09:00'],
        [rule({ trigger: 'after_checkout', offsetDays: 1, sendTime: '11:00' }), '1 jour après le départ à 11:00'],
        [rule({ trigger: 'after_checkout', offsetDays: 3, sendTime: '11:00' }), '3 jours après le départ à 11:00'],
        [rule({ trigger: 'booking_confirmed', sendTime: null, delayMinutes: 30 }), '30 min après la réservation'],
        [rule({ trigger: 'booking_confirmed', sendTime: null, delayMinutes: 120 }), '2 h après la réservation'],
        [rule({ trigger: 'booking_confirmed', sendTime: null, delayMinutes: 90 }), '1 h 30 après la réservation'],
        [rule({ trigger: 'booking_confirmed', sendTime: null, delayMinutes: 0 }), 'Dès la réservation'],
    ])('FR : %o → %s', (parts, expected) => {
        expect(describeTrigger(parts, 'fr')).toBe(expected);
    });

    it.each([
        [rule({ trigger: 'before_checkin', offsetDays: 2 }), '2 days before check-in at 10:00'],
        [rule({ trigger: 'before_checkin', offsetDays: 1 }), '1 day before check-in at 10:00'],
        [rule({ trigger: 'checkin_day', sendTime: '14:00' }), 'On check-in day at 14:00'],
        [rule({ trigger: 'during_stay', offsetDays: 1 }), '1 day after check-in at 10:00'],
        [rule({ trigger: 'before_checkout', offsetDays: 2 }), '2 days before check-out at 10:00'],
        [rule({ trigger: 'checkout_day', sendTime: '09:00' }), 'On check-out day at 09:00'],
        [rule({ trigger: 'after_checkout', offsetDays: 1, sendTime: '11:00' }), '1 day after check-out at 11:00'],
        [rule({ trigger: 'booking_confirmed', sendTime: null, delayMinutes: 30 }), '30 min after booking'],
        [rule({ trigger: 'booking_confirmed', sendTime: null, delayMinutes: 0 }), 'Right after booking'],
    ])('EN : %o → %s', (parts, expected) => {
        expect(describeTrigger(parts, 'en')).toBe(expected);
    });

    it('ignore le décalage le jour de l’arrivée et du départ, comme le moteur', () => {
        expect(describeTrigger(rule({ trigger: 'checkin_day', offsetDays: 5 }), 'fr')).toBe("Le jour de l'arrivée à 10:00");
        expect(describeTrigger(rule({ trigger: 'checkout_day', offsetDays: 5 }), 'fr')).toBe('Le jour du départ à 10:00');
    });

    it('sans heure, la phrase s’arrête au jour (pas de « à null »)', () => {
        expect(describeTrigger(rule({ trigger: 'before_checkin', offsetDays: 1, sendTime: null }), 'fr')).toBe("1 jour avant l'arrivée");
    });

    it('une locale inconnue retombe sur le français', () => {
        expect(describeTrigger(rule({}), 'ar')).toBe("1 jour avant l'arrivée à 10:00");
    });
});

describe('formatDelay', () => {
    it.each([
        [0, '0 min'],
        [5, '5 min'],
        [60, '1 h'],
        [65, '1 h 05'],
        [1440, '24 h'],
    ])('%i → %s', (minutes, expected) => {
        expect(formatDelay(minutes)).toBe(expected);
    });
});

describe('déclencheurs : décalage et heure', () => {
    it('seuls les déclencheurs comptés en jours lisent le décalage', () => {
        expect(['before_checkin', 'during_stay', 'before_checkout', 'after_checkout'].every((t) => usesOffset(t as TriggerParts['trigger']))).toBe(
            true,
        );
        expect(usesOffset('checkin_day')).toBe(false);
        expect(usesOffset('checkout_day')).toBe(false);
        expect(usesOffset('booking_confirmed')).toBe(false);
    });

    it('seul « après la réservation » se passe d’heure', () => {
        expect(usesSendTime('booking_confirmed')).toBe(false);
        expect(usesSendTime('checkin_day')).toBe(true);
    });
});

describe('insertAtCursor — une variable au curseur', () => {
    it('insère à la position du curseur et place le curseur après', () => {
        expect(insertAtCursor('Bonjour , bienvenue', '{prenom}', 8, 8)).toEqual({ text: 'Bonjour {prenom}, bienvenue', cursor: 16 });
    });

    it('remplace la sélection', () => {
        expect(insertAtCursor('Bonjour Alice', '{prenom}', 8, 13)).toEqual({ text: 'Bonjour {prenom}', cursor: 16 });
    });

    it('accepte une sélection à l’envers et des bornes hors du texte', () => {
        expect(insertAtCursor('abc', 'X', 3, 1)).toEqual({ text: 'aX', cursor: 2 });
        expect(insertAtCursor('abc', 'X', 99, 99)).toEqual({ text: 'abcX', cursor: 4 });
        expect(insertAtCursor('abc', 'X', -4, -1)).toEqual({ text: 'Xabc', cursor: 1 });
        expect(insertAtCursor('', '{nom}', 0, 0)).toEqual({ text: '{nom}', cursor: 5 });
    });
});

describe('variables du texte — même motif que le serveur', () => {
    it('trouve les variables sans doublon, dans l’ordre', () => {
        expect(tokensIn('{prenom} {logement} {prenom} {prénom} { espace }')).toEqual(['prenom', 'logement', 'prénom']);
    });

    it('signale les variables inconnues, fautes de casse et d’accent comprises', () => {
        expect(unknownTokens('Bonjour {Prenom}, {prénom} ou {prenom}', ['prenom', 'nom'])).toEqual(['Prenom', 'prénom']);
        expect(unknownTokens('Aucun jeton', ['prenom'])).toEqual([]);
    });
});

describe('état du planificateur', () => {
    const now = new Date('2026-09-28T12:00:00Z');

    it('jamais passé', () => {
        expect(lastRunState(null, now)).toBe('never');
        expect(lastRunState('pas une date', now)).toBe('never');
    });

    it('passage horaire : à jour jusqu’à 90 min ; au-delà : en retard', () => {
        expect(lastRunState('2026-09-28T11:05:00Z', now)).toBe('fresh'); // passage normal, 55 min
        expect(lastRunState('2026-09-28T10:31:00Z', now)).toBe('fresh');
        expect(lastRunState('2026-09-28T10:29:00Z', now)).toBe('stale');
    });
});

describe('couleurs des statuts : un sens, et un seul', () => {
    it.each([
        ['sent', 'green'],
        ['pending_approval', 'amber'],
        ['waiting_channel', 'amber'],
        ['retry', 'amber'],
        ['failed', 'red'],
        ['skipped', 'gray'],
        ['cancelled', 'gray'],
    ] as const)('%s → %s', (status, tone) => {
        expect(statusTone(status)).toBe(tone);
    });
});

describe('résultat inconnu', () => {
    it('reconnaît les motifs du moteur (délai dépassé, envoi interrompu, erreur en plein appel)', () => {
        expect(isUnknownResult('Résultat inconnu (délai dépassé) — vérifier le fil avant de renvoyer')).toBe(true);
        expect(isUnknownResult('Envoi interrompu, résultat inconnu — vérifier le fil avant de renvoyer')).toBe(true);
        expect(isUnknownResult('Refusé par la messagerie du canal (HTTP 422)')).toBe(false);
        expect(isUnknownResult(null)).toBe(false);
    });
});

describe('dates et gabarits', () => {
    it('jj/mm/aaaa HH:MM, dans le fuseau local', () => {
        const iso = new Date(2026, 8, 28, 14, 5, 33).toISOString();
        expect(formatDateTimeShort(iso)).toBe('28/09/2026 14:05');
        expect(formatDateTimeShort(null)).toBe('');
        expect(formatDateTimeShort('illisible')).toBe('illisible');
    });

    it('fill remplace chaque :clé, par mot entier', () => {
        expect(fill(':sent envoyés · :pending à valider · :sent', { sent: 3, pending: 1 })).toBe('3 envoyés · 1 à valider · 3');
        // `:to` ne doit pas mordre sur `:total`.
        expect(fill(':from–:to sur :total', { from: 1, to: 50, total: 120 })).toBe('1–50 sur 120');
        expect(fill('Dernière exécution :when', {})).toBe('Dernière exécution :when');
    });
});
