import type { CompteursDepots, DepotEtat, RetenueCan, RetenueDeclaration, RetenueLigne } from './types';

/**
 * Ce que l'écran a le droit d'AFFIRMER sur un dépôt chez la DGI.
 *
 * ═════════════════════════════════════════════════════════════════════════════
 * POURQUOI CES FONCTIONS VIVENT ICI ET PAS DANS LE JSX
 * ═════════════════════════════════════════════════════════════════════════════
 * Vitest ne collecte, sous `resources/js`, que les fichiers `.test.ts` — jamais
 * un `.tsx` — et il les exécute en environnement `node` : aucun DOM, aucun
 * `@testing-library` au projet. Une règle écrite dans un
 * composant serait donc INTESTABLE. Or la règle en question décide s'il faut
 * afficher, sur une ligne de déclaration fiscale, un badge vert qui affirme
 * qu'une administration a accepté un dépôt. C'est exactement le genre de phrase
 * qu'on veut pouvoir figer par un test nommé.
 *
 * Le patron est celui, invariant, du dossier : `format.ts`/`format.test.ts`,
 * `fournisseurs/tej.ts`/`tej.test.ts`, `messagerie/mobile-pane.ts` — un module
 * frère porte la décision, le composant ne fait que la rendre. Il vaut aussi
 * pour l'actualisation AUTOMATIQUE de l'état DGI : quand relire, combien de
 * fois, à quel rythme — ce sont des appels à une plateforme plafonnée, la
 * règle doit se tester sans monter d'écran.
 *
 * ═════════════════════════════════════════════════════════════════════════════
 * CE QUE CE MODULE NE FAIT PAS, ET NE DOIT JAMAIS FAIRE
 * ═════════════════════════════════════════════════════════════════════════════
 * Il ne CLASSE aucun code de la DGI. Les seize libellés (`NOT_VALID`,
 * `CERTIFICATES_GENERATED`, `WAITING_VALIDATION`…) sont rangés en PHP par
 * `app/Services/Tej/TejEtatDepot.php`, autorité unique, testée code par code.
 * Le serveur envoie ici un état DÉJÀ RÉDUIT (`DepotEtat.etat`). Recopier la
 * table en TypeScript créerait une seconde source de vérité fiscale, qui
 * divergerait au premier code ajouté par la plateforme — en silence, et du
 * mauvais côté : la case la plus commode.
 *
 * ═════════════════════════════════════════════════════════════════════════════
 * TROIS MOTS, DANS CEUX DE L'UTILISATEUR
 * ═════════════════════════════════════════════════════════════════════════════
 * « En attente DGI », « Accepté par la DGI », « Refusé par la DGI ». Ce sont
 * les mots qu'il emploie pour parler de ses envois, et ce sont les trois seuls
 * que la ligne porte. Le refus manquait tout entier : l'écran savait dire
 * « en analyse » et « accepté », il ne savait pas dire « non ».
 *
 * La règle qui gouverne le reste ne change pas : UNE ABSENCE N'AFFIRME RIEN.
 * Une ligne jamais envoyée n'a pas de badge — les ~245 lignes importées de
 * XLSX n'ont jamais eu d'empreinte de fichier, et ce n'est pas un défaut
 * d'affichage. Une ligne ENVOYÉE, en revanche, n'est jamais muette : tant que
 * la DGI ne montre rien, elle est « En attente DGI ». C'est notre envoi qui le
 * prouve, pas une lecture chez elle.
 */

/**
 * Le traducteur de `useTranslation()`, réduit à ce qu'on en utilise.
 *
 * Déclaré ici plutôt qu'importé : le hook lit `usePage()` d'Inertia, donc React,
 * donc un module que l'environnement `node` de Vitest ne peut pas charger. Le
 * test passe un faux traducteur d'une ligne (patron `fournisseurs/tej.test.ts`).
 */
export type Traducteur = (namespace: string, key: string, fallback?: string) => string;

/** Ce qu'il faut pour peindre un badge, et rien de plus. */
export interface BadgeDepot {
    /**
     * Classes Tailwind à poser sur un `<Badge variant="outline">`.
     *
     * ⚠️ LE REFUS EST ROUGE, L'ATTENTE EST AMBER — et ce n'était pas le cas.
     * Le refus était peint en amber, au motif que le mot portait déjà la
     * gravité. C'était tenable tant que l'amber ne voulait rien dire d'autre.
     * Il veut désormais dire « En attente DGI » : un refus peint de la même
     * couleur qu'une attente se lirait comme une attente, c'est-à-dire comme
     * quelque chose qui se réglera tout seul. C'est aussi le code du portail de
     * la DGI lui-même : `NOT_VALID` et les non-conformités y sont en rouge,
     * l'analyse et le traitement en orange.
     */
    classe: string;
    libelle: string;
    /**
     * DES FAITS, AUCUNE INTERPRÉTATION — et aucune ligne pour un champ absent.
     *
     * « Dépôt n° null » serait pire que le silence : l'écran aurait l'air de
     * savoir. Un champ absent ne produit tout simplement pas sa ligne.
     */
    lignesInfobulle: string[];
}

/**
 * Les états de `tej_statut` (le CERTIFICAT) qui l'emportent sur le dépôt.
 *
 * `CANCELLED` est le code rendu par la plateforme ; `ANNULE` est la forme que
 * portent les lignes importées du compte réel. Les deux sont acceptées parce
 * que les deux existent en base — et se tromper ici afficherait « Accepté par
 * la DGI » sur une retenue dont le certificat n'existe plus.
 */
const CERTIFICAT_ANNULE: readonly string[] = ['CANCELLED', 'ANNULE'];

/** Au-delà, l'écran cesse d'affirmer et commence à dater ce qu'il montre. */
const FRAICHEUR_MS = 24 * 60 * 60 * 1000;

const MINUTE_MS = 60 * 1000;
const HEURE_MS = 60 * MINUTE_MS;

/**
 * Le rythme de la relecture AUTOMATIQUE de l'état DGI — trois nombres, un plafond.
 *
 * ═════════════════════════════════════════════════════════════════════════════
 * POURQUOI L'ÉCRAN RELIT LUI-MÊME, ET POURQUOI SI PEU
 * ═════════════════════════════════════════════════════════════════════════════
 * Les dépôts partent du manager EN LIGNE, et c'est cette même instance qui sait
 * relire leur état. Une relecture confiée à une tâche planifiée n'avait jamais
 * tourné là où les envois se font : l'utilisateur envoyait une ligne, et
 * l'écran ne découvrait jamais ni l'acceptation ni le refus. Il préfère
 * l'automatique au manuel ; l'écran relit donc de lui-même, tant qu'un envoi
 * attend son verdict.
 *
 * Mais la plateforme plafonne à 500 appels par jour pour tout le compte, et
 * une relecture en coûte plusieurs. D'où trois garde-fous cumulés :
 *   - on ne relit que si l'état affiché a plus de CINQ minutes ;
 *   - deux relectures automatiques sont espacées d'au moins cinq minutes,
 *     qu'elles aient réussi ou non — une DGI injoignable ne doit pas être
 *     rappelée à chaque battement ;
 *   - DOUZE tentatives au plus par ONGLET : un onglet oublié ouvert une nuit
 *     entière ne vide pas le quota du lendemain. Le compte vit HORS du
 *     composant (`memoireActualisation()`) : chaque enregistrement de ligne
 *     ou de paiement REMONTE l'écran (Inertia recrée la page sans
 *     `preserveState`), et un compteur tenu dans un `useRef` repartait alors
 *     de zéro — le plafond et l'espacement ne tenaient qu'entre deux clics.
 *
 * Le battement d'une minute est une vérification LOCALE, sans réseau : c'est
 * lui qui permet de relire « dès que l'état a cinq minutes », et non « toutes
 * les cinq minutes après l'ouverture » — ce qui, relecture comprise, aurait
 * raté la fenêtre d'une poignée de secondes et doublé l'attente.
 */
export const ACTUALISATION_AUTO = {
    /** Un état plus vieux que cela, avec un envoi en attente, justifie une relecture. */
    ageMaxMs: 5 * MINUTE_MS,
    /** Écart minimal entre deux relectures automatiques, réussies ou non. */
    espacementMs: 5 * MINUTE_MS,
    /** Plafond par onglet — environ une heure au rythme ci-dessus. */
    maxTentatives: 12,
    /** Cadence de la vérification locale. N'appelle PERSONNE à elle seule. */
    battementMs: MINUTE_MS,
} as const;

/**
 * ISO 8601 → « 05/08/2026 à 12:16 », par LECTURE de la chaîne.
 *
 * Surtout pas `new Date(iso).toLocaleString()` : la date part du serveur dans le
 * fuseau du déclarant, et la convertir dans celui du navigateur décalerait
 * l'heure d'un dépôt fiscal — d'un jour entier pour un dépôt de 23 h 41, qui
 * est précisément l'heure des dépôts que ce chantier existe pour rattraper. On
 * affiche les composantes telles qu'elles ont été écrites.
 */
export function dateHeureFr(iso: string | null | undefined): string {
    const m = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/.exec(iso ?? '');

    if (!m) return '';

    return `${m[3]}/${m[2]}/${m[1]} à ${m[4]}:${m[5]}`;
}

/** Même lecture, forme courte de la bande de statut : « 02/09 à 06:12 ». */
export function dateHeureCourteFr(iso: string | null | undefined): string {
    const m = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/.exec(iso ?? '');

    if (!m) return '';

    return `${m[3]}/${m[2]} à ${m[4]}:${m[5]}`;
}

/**
 * Le badge d'une ligne — ou `null` quand elle n'a jamais été envoyée.
 *
 * ═════════════════════════════════════════════════════════════════════════════
 * LES QUATRE RÈGLES, DANS CET ORDRE, ET L'ORDRE EST LE FOND
 * ═════════════════════════════════════════════════════════════════════════════
 * 1. LE CYCLE DE VIE DU CERTIFICAT PRIME SUR CELUI DU DÉPÔT. `tej_statut`
 *    annulé ⇒ « Certificat annulé chez la DGI », et on s'arrête là. L'état du
 *    dépôt qualifie le FICHIER, celui du certificat qualifie la RETENUE : un
 *    fichier accepté ne dit jamais que le certificat qu'il portait existe
 *    encore. Sans cette règle, une annulation faite à la main sur le portail
 *    s'afficherait « Accepté par la DGI ».
 * 2. JAMAIS ENVOYÉE ⇒ AUCUN BADGE. Les ~245 lignes importées. On n'invente pas
 *    de « inconnu ».
 * 3. CODE NON TRADUIT ⇒ ON LE CITE. « État DGI : CANCEL », en gris, avec une
 *    infobulle qui dit que nous ne savons pas le traduire. Ranger `CANCEL` en
 *    « en attente » serait faux ; en « refusé » aussi, et ce serait pire :
 *    « refusé » est le mot qui déclenche un second envoi.
 * 4. UNE FAMILLE ⇒ LE MOT DE L'UTILISATEUR. Trois mots, jamais d'autres.
 *
 * Il n'y a plus de règle « dépôts divergents ⇒ silence ». Deux dépôts du même
 * fichier aux verdicts opposés existent pour de vrai (refusé le 27/08, accepté
 * le 10/09) ; le serveur retient désormais le plus récent, et se taire
 * cachait précisément cette acceptation.
 *
 * La fraîcheur (« Vérifié le … ») n'est pas une règle qui filtrerait : elle
 * voyage dans l'infobulle, en dernier fait, pour que le lecteur sache toujours
 * DE QUAND date ce qu'il lit.
 */
export function badgeDepot(ligne: Pick<RetenueLigne, 'tej_depot' | 'tej_statut'>, t: Traducteur): BadgeDepot | null {
    const depot = ligne.tej_depot;

    // ── Règle 1 — le certificat l'emporte, quoi que dise le dépôt. ──────────
    if (certificatAnnule(ligne)) {
        return {
            classe: 'border-transparent bg-muted text-muted-foreground',
            libelle: t('retenue', 'depot_certificat_annule', 'Certificat annulé chez la DGI'),
            lignesInfobulle: faits(depot, t),
        };
    }

    // ── Règle 2 — jamais envoyée, donc rien à dire. ─────────────────────────
    if (!depot) return null;

    // ── Règle 3 — un code que nous ne savons pas classer se CITE. ───────────
    if (depot.etat === 'non_traduit') {
        // Sans le code brut, il n'y a rien à citer : citer « État DGI : » suivi
        // de rien serait affirmer qu'on a lu quelque chose. Fail-closed.
        if (!depot.code) return null;

        return {
            classe: 'border-transparent bg-muted text-muted-foreground',
            libelle: t('retenue', 'depot_code_non_traduit', 'État DGI : :code').replace(':code', depot.code),
            lignesInfobulle: [
                ...faits(depot, t),
                t('retenue', 'depot_code_non_traduit_aide', 'Code rendu par la DGI, que le manager ne sait pas encore traduire.'),
            ],
        };
    }

    // ── Règle 4 — les trois seuls mots que cet écran a le droit de dire. ────
    // `border-emerald-300 bg-emerald-50 text-emerald-800` est repris tel quel du
    // badge « payé » de la même ligne : le vert de cet écran veut dire « c'est
    // acquis », il ne doit pas exister deux verts qui disent presque la même chose.
    switch (depot.etat) {
        case 'accepte':
            return {
                classe: 'border-emerald-300 bg-emerald-50 text-emerald-800',
                libelle: t('retenue', 'depot_accepte', 'Accepté par la DGI'),
                lignesInfobulle: faits(depot, t),
            };

        case 'en_cours':
            return {
                // L'amber de l'écran : « ce qui retient ». Ici, ce qui attend.
                classe: 'border-amber-300 bg-amber-50 text-amber-900',
                libelle: t('retenue', 'depot_en_cours', 'En attente DGI'),
                lignesInfobulle: faitsAttente(depot, t),
            };

        case 'a_refaire': {
            // ENVOYÉE, JAMAIS REÇUE : il y a quelque chose à refaire, mais la
            // DGI n'a rien refusé — elle n'a rien vu. « Refusé par la DGI » sur
            // un fichier qu'elle n'a jamais reçu serait une affirmation fausse,
            // et « En attente DGI » (ce que l'écran disait) une attente sans fin
            // à côté d'un « Envoyer à TEJ… » armé.
            if (depot.non_recu === true) {
                return {
                    classe: 'border-red-300 bg-red-50 text-red-800',
                    libelle: t('retenue', 'depot_non_recu', 'Non reçu par la DGI'),
                    lignesInfobulle: faitsNonRecu(depot, t),
                };
            }

            const motif = motifDe(depot);

            return {
                classe: 'border-red-300 bg-red-50 text-red-800',
                libelle: t('retenue', 'depot_a_refaire', 'Refusé par la DGI'),
                // Le MOTIF d'abord : c'est la seule ligne qui dit quoi corriger.
                // Il est le texte de la DGI, extrait de son rapport — jamais une
                // raison que nous aurions devinée. Remplacé par une FONCTION :
                // un texte libre venu d'ailleurs peut contenir `$&`, que
                // `replace()` interpréterait comme un motif de remplacement.
                lignesInfobulle: [
                    ...(motif ? [t('retenue', 'depot_motif', 'Motif : :motif').replace(':motif', () => motif)] : []),
                    ...faits(depot, t),
                ],
            };
        }
    }

    // Un état que ce module ne connaît pas (serveur plus récent que l'écran) :
    // on se tait plutôt que de peindre une case au hasard.
    return null;
}

/**
 * Les faits de l'infobulle, dans l'ordre, et JAMAIS de ligne pour un vide.
 *
 * L'ordre n'est pas décoratif : le numéro de dépôt est ce qu'on retape sur le
 * portail de la DGI, la date de dépôt est ce qu'on compare à l'échéance, le
 * code brut est ce qu'on lit dans leur rapport, et la date de vérification est
 * ce qui dit si les trois précédents valent encore quelque chose.
 */
function faits(depot: DepotEtat | null, t: Traducteur): string[] {
    if (!depot) return [];

    const lignes: string[] = [];

    if (depot.numero) {
        lignes.push(t('retenue', 'depot_numero', 'Dépôt n° :numero').replace(':numero', depot.numero));
    }

    const depose = dateHeureFr(depot.depose_at);
    if (depose !== '') {
        lignes.push(t('retenue', 'depot_depose_le', 'Déposé le :date').replace(':date', depose));
    }

    if (depot.code) {
        // Le code BRUT d'abord, sa traduction entre parenthèses ensuite — et
        // seulement si nous en avons une. C'est le code que la DGI affiche sur
        // son portail : c'est lui qui permet de retrouver le dépôt là-bas.
        const cite = depot.libelle_dgi ? `${depot.code} (${depot.libelle_dgi})` : depot.code;

        lignes.push(t('retenue', 'depot_code_non_traduit', 'État DGI : :code').replace(':code', cite));
    }

    pousserVerification(lignes, depot, t);

    return lignes;
}

/**
 * Les faits d'une ligne « En attente DGI » — NOTRE envoi d'abord, SA lecture ensuite.
 *
 * Entre l'envoi et le moment où la DGI rend le dépôt visible, le seul fait
 * disponible est le nôtre : « Envoyé à TEJ le … ». Dès qu'elle le montre, on
 * ajoute ce qu'elle en dit, dans ses mots (« Envoyé à l'analyse ») et avec
 * son numéro — celui qu'on retape sur son portail pour le retrouver.
 *
 * Tant qu'elle ne montre rien, on le DIT, sans quoi l'infobulle d'une ligne
 * tout juste envoyée ressemblerait à celle d'une ligne perdue.
 */
function faitsAttente(depot: DepotEtat, t: Traducteur): string[] {
    const lignes: string[] = [];

    const envoye = dateHeureFr(depot.envoye_at);
    const depose = dateHeureFr(depot.depose_at);

    if (envoye !== '') {
        lignes.push(t('retenue', 'depot_envoye_le', 'Envoyé à TEJ le :date').replace(':date', envoye));
    } else if (depose !== '') {
        // Empreinte posée par rattachement, sans trace de notre envoi : la
        // date de la DGI est alors la seule qu'on ait.
        lignes.push(t('retenue', 'depot_depose_le', 'Déposé le :date').replace(':date', depose));
    }

    const etatTej = depot.libelle_dgi ?? depot.code;

    if (etatTej && depot.numero) {
        lignes.push(t('retenue', 'depot_tej_etat_numero', 'TEJ : :etat — dépôt :numero').replace(':etat', etatTej).replace(':numero', depot.numero));
    } else if (etatTej) {
        lignes.push(t('retenue', 'depot_tej_etat', 'TEJ : :etat').replace(':etat', etatTej));
    } else if (depot.numero) {
        lignes.push(t('retenue', 'depot_numero', 'Dépôt n° :numero').replace(':numero', depot.numero));
    } else {
        lignes.push(
            t(
                'retenue',
                'depot_attente_aide',
                'Pas encore visible chez la DGI — l’état se revérifie à l’ouverture de l’écran et avec « Actualiser l’état DGI ».',
            ),
        );
    }

    pousserVerification(lignes, depot, t);

    return lignes;
}

/**
 * Les faits d'une ligne « Non reçu par la DGI » — NOTRE envoi, puis le constat.
 *
 * Il n'y a pas de dépôt à citer : c'est précisément son absence, relevée par
 * une lecture COMPLÈTE de la liste de la DGI plus de dix minutes après
 * l'envoi, qui fonde le constat. La date de vérification dit de quand il date.
 */
function faitsNonRecu(depot: DepotEtat, t: Traducteur): string[] {
    const lignes: string[] = [];

    const envoye = dateHeureFr(depot.envoye_at);

    if (envoye !== '') {
        lignes.push(t('retenue', 'depot_envoye_le', 'Envoyé à TEJ le :date').replace(':date', envoye));
    }

    lignes.push(
        t(
            'retenue',
            'depot_non_recu_aide',
            'La DGI ne liste aucun dépôt de ce fichier : il ne lui est jamais parvenu. Renvoyez la ligne depuis son menu.',
        ),
    );

    pousserVerification(lignes, depot, t);

    return lignes;
}

/** Le dernier fait de toute infobulle : DE QUAND date ce qu'on vient de lire. */
function pousserVerification(lignes: string[], depot: DepotEtat, t: Traducteur): void {
    const verifie = dateHeureCourteFr(depot.verifie_at);

    if (verifie !== '') {
        lignes.push(t('retenue', 'depot_verifie_le', 'Vérifié le :date').replace(':date', verifie));
    }
}

/** Le motif, débarrassé de ses blancs — ou `null`, jamais une chaîne vide. */
function motifDe(depot: DepotEtat): string | null {
    const motif = (depot.motif ?? '').trim();

    return motif === '' ? null : motif;
}

/**
 * Le motif du refus de CETTE ligne, s'il y a un refus à motiver.
 *
 * Même garde que le badge : une ligne dont le certificat a été annulé n'est
 * plus « refusée », et lui accoler un motif réclamerait une correction sur une
 * retenue qui n'existe plus chez la DGI.
 */
export function motifDuRefus(ligne: Pick<RetenueLigne, 'tej_depot' | 'tej_statut'>): string | null {
    if (ligne.tej_depot?.etat !== 'a_refaire' || certificatAnnule(ligne)) return null;

    return motifDe(ligne.tej_depot);
}

/**
 * Le certificat de cette ligne est-il annulé chez la DGI ? — LA RÈGLE 1, SEULE.
 *
 * Exportée pour que le bandeau des refus n'ait pas à la réécrire. Deux copies
 * de cette règle finiraient par diverger, et la divergence prendrait la forme
 * suivante : la ligne afficherait « Certificat annulé » pendant que le bandeau
 * réclamerait de la corriger et de la renvoyer.
 */
export function certificatAnnule(ligne: Pick<RetenueLigne, 'tej_statut'>): boolean {
    return CERTIFICAT_ANNULE.includes((ligne.tej_statut ?? '').trim().toUpperCase());
}

/**
 * Les lignes À REFAIRE — refusées par la DGI, ou envoyées et jamais reçues —
 * celles que le bandeau doit nommer.
 *
 * Les deux cas sont dans `a_refaire` (et dans son compteur, qui sert au
 * bandeau à dire ce que le filtre masque) : les en exclure ferait compter
 * « masquée par le filtre » une ligne pourtant affichée. Le bandeau les
 * distingue ensuite par `non_recu`.
 *
 * Un certificat annulé sort de la liste : sa retenue n'existe plus chez la DGI,
 * la renvoyer serait une seconde déclaration et non une correction.
 */
export function lignesRefusees<T extends Pick<RetenueLigne, 'tej_depot' | 'tej_statut'>>(lignes: T[]): T[] {
    return lignes.filter((l) => l.tej_depot?.etat === 'a_refaire' && !certificatAnnule(l));
}

/**
 * Ce qu'on affiche a-t-il plus de 24 h — ou n'a-t-il jamais été vérifié ?
 *
 * `null` REND `true`, et ce n'est pas une anomalie : un compte qui n'a jamais
 * rien envoyé, un compte franchisé (la DGI ne se relit que depuis le compte
 * principal), ou une instance où l'intégration est coupée n'ont jamais rien
 * vérifié. L'écran doit être conçu pour cet état, pas le traiter comme une panne.
 *
 * Une date illisible rend `true` elle aussi : entre « je ne sais pas dater ce
 * que je montre » et « c'est frais », le seul choix sûr est le premier.
 */
export function depotEstPerime(verifieAt: string | null, maintenant: Date): boolean {
    if (!verifieAt) return true;

    const t = Date.parse(verifieAt);

    if (Number.isNaN(t)) return true;

    return maintenant.getTime() - t > FRAICHEUR_MS;
}

/**
 * « État DGI vérifié il y a 3 min » — la fraîcheur, dite comme on la pense.
 *
 * Une heure absolue (« vérifié le 24/09 à 15:22 ») oblige à regarder l'horloge
 * pour savoir si c'est récent ; la question que se pose l'utilisateur devant
 * un envoi en attente est « est-ce que ça vient d'être regardé ? ». Au-delà de
 * 24 h la date absolue revient : « il y a 73 h » ne se lit plus.
 *
 * Une date dans le futur — horloge du poste en retard sur le serveur — se lit
 * « à l'instant » : c'est ce qu'elle veut dire, et « il y a -2 min » serait
 * une absurdité affichée sur une déclaration fiscale.
 */
export function fraicheurDgi(verifieAt: string | null, maintenant: Date, t: Traducteur): string {
    const jamais = t('retenue', 'depot_jamais_verifie', 'État DGI jamais vérifié');

    if (!verifieAt) return jamais;

    const instant = Date.parse(verifieAt);

    if (Number.isNaN(instant)) {
        const lu = dateHeureCourteFr(verifieAt);

        return lu === '' ? jamais : t('retenue', 'depot_etat_verifie_le', 'État DGI vérifié le :date').replace(':date', lu);
    }

    const ecart = maintenant.getTime() - instant;

    if (ecart < MINUTE_MS) {
        return t('retenue', 'depot_verifie_instant', 'État DGI vérifié à l’instant');
    }

    if (ecart < HEURE_MS) {
        return t('retenue', 'depot_verifie_minutes', 'État DGI vérifié il y a :minutes min').replace(
            ':minutes',
            String(Math.floor(ecart / MINUTE_MS)),
        );
    }

    if (ecart <= FRAICHEUR_MS) {
        return t('retenue', 'depot_verifie_heures', 'État DGI vérifié il y a :heures h').replace(':heures', String(Math.floor(ecart / HEURE_MS)));
    }

    return t('retenue', 'depot_etat_verifie_le', 'État DGI vérifié le :date').replace(':date', dateHeureCourteFr(verifieAt));
}

/** Ce qu'il faut se rappeler d'une relecture automatique à l'autre. */
export interface MemoireActualisation {
    /** Relectures automatiques lancées depuis l'ouverture de l'onglet. */
    tentatives: number;
    /** Horodatage (ms) de la dernière, `null` si aucune. */
    dernierEssai: number | null;
}

const memoires = new Map<string, MemoireActualisation>();

/**
 * La mémoire des relectures automatiques, HORS de tout composant.
 *
 * ═════════════════════════════════════════════════════════════════════════════
 * POURQUOI UNE VARIABLE DE MODULE, ET PAS UN `useRef`
 * ═════════════════════════════════════════════════════════════════════════════
 * Inertia 2 recrée la page à chaque visite qui ne passe pas `preserveState` —
 * l'enregistrement d'un paiement, d'une ligne, une suppression. Un `useRef`
 * repartait alors de zéro : le plafond de douze relectures et l'espacement de
 * cinq minutes ne tenaient qu'entre deux enregistrements, et chaque remontage
 * relançait une relecture sur-le-champ. Une variable de module vit autant que
 * l'onglet : c'est exactement la portée du plafond.
 *
 * Une clé par écran (`cle`) : deux écrans qui relisent la DGI ne se
 * partagent pas leur budget par accident.
 */
export function memoireActualisation(cle: string): MemoireActualisation {
    let memoire = memoires.get(cle);

    if (!memoire) {
        memoire = { tentatives: 0, dernierEssai: null };
        memoires.set(cle, memoire);
    }

    return memoire;
}

/** Tout ce que la décision d'une relecture automatique a le droit de regarder. */
export interface ContexteActualisation {
    /** `can.actualiser_tej` — compte principal, intégration active. */
    peut: boolean;
    /** Envois en attente de verdict sur la déclaration affichée (non filtrés). */
    enCours: number;
    verifieAt: string | null;
    maintenant: Date;
    /** Relectures automatiques déjà lancées dans cet onglet (`memoireActualisation()`). */
    tentatives: number;
    /** Horodatage (ms) de la dernière relecture automatique lancée, `null` si aucune. */
    dernierEssai: number | null;
    /** `document.visibilityState === 'visible'` — un onglet caché ne relit rien. */
    visible: boolean;
    /** Une relecture est déjà en vol : on n'en empile pas une seconde. */
    enVol: boolean;
}

/**
 * Faut-il relire la DGI MAINTENANT, sans que personne ne l'ait demandé ?
 *
 * Toutes les conditions, et chacune a son dommage :
 *   - pas le droit           → un compte franchisé appellerait une route refusée ;
 *   - rien en attente        → relire des verdicts rendus ne change rien ;
 *   - onglet caché           → personne ne regarde, le quota paie quand même ;
 *   - déjà en vol            → deux relectures concurrentes se marchent dessus ;
 *   - plafond atteint        → un onglet oublié viderait le quota du lendemain ;
 *   - essai trop récent      → une DGI injoignable serait rappelée à chaque minute ;
 *   - état encore frais      → l'utilisateur vient de cliquer « Actualiser ».
 *
 * Une date de vérification illisible compte pour « jamais vérifié » : c'est le
 * seul cas où relire est sûr ET utile.
 */
export function doitActualiserAuto(c: ContexteActualisation): boolean {
    if (!c.peut || c.enVol || !c.visible) return false;

    if (c.enCours <= 0) return false;

    if (c.tentatives >= ACTUALISATION_AUTO.maxTentatives) return false;

    const maintenant = c.maintenant.getTime();

    if (c.dernierEssai !== null && maintenant - c.dernierEssai < ACTUALISATION_AUTO.espacementMs) return false;

    if (!c.verifieAt) return true;

    const verifie = Date.parse(c.verifieAt);

    if (Number.isNaN(verifie)) return true;

    return maintenant - verifie > ACTUALISATION_AUTO.ageMaxMs;
}

/**
 * « 9 acceptés · 2 en attente DGI · 1 refusé », ou `null`.
 *
 * ═════════════════════════════════════════════════════════════════════════════
 * LA FRAÎCHEUR PRIME SUR TOUT LE RESTE, ET ELLE PRIME ICI
 * ═════════════════════════════════════════════════════════════════════════════
 * Jamais vérifié ⇒ AUCUN compteur, pas même un « 0 refusé » rassurant. Un
 * zéro affiché est une affirmation : il dit « nous avons regardé et il n'y a
 * rien ». Tant que `depot_verifie_at` est `null`, nous n'avons pas regardé.
 * C'est la seule chose qui empêche cet écran d'affirmer, la veille d'une
 * échéance déclarative, la vérité d'il y a trois semaines.
 *
 * Un compteur à zéro ne produit pas non plus son segment : « 0 refusé » à
 * côté de « 9 acceptés » n'apprend rien et dilue le seul chiffre qui compte.
 *
 * Les deux compteurs restants — `non_traduits` et `sans_verdict` — ne sont pas
 * dans cette ligne à dessein : ce ne sont pas des verdicts. L'un est une
 * citation, l'autre une absence, et chacun se lit déjà là où il vit (le badge
 * gris de la ligne, et l'absence de badge).
 */
export function recapDepot(declaration: Pick<RetenueDeclaration, 'depots' | 'depot_verifie_at'>, t: Traducteur): string | null {
    // Jamais vérifié : on ne compte rien. Voir le pavé ci-dessus.
    if (!declaration.depot_verifie_at) return null;

    // Garde de survie : la clé `depots` naît avec la sérialisation de l'étape
    // 10. Un écran servi par une version antérieure ne doit pas casser — il
    // doit simplement ne rien affirmer, ce qui est déjà la bonne réponse.
    const depots: CompteursDepots | undefined = declaration.depots;

    if (!depots) return null;

    const segments: string[] = [];

    if (depots.acceptes > 0) {
        segments.push(
            depots.acceptes === 1
                ? t('retenue', 'depot_recap_accepte_un', '1 accepté')
                : t('retenue', 'depot_recap_acceptes', ':count acceptés').replace(':count', String(depots.acceptes)),
        );
    }

    if (depots.en_cours > 0) {
        segments.push(t('retenue', 'depot_recap_en_cours', ':count en attente DGI').replace(':count', String(depots.en_cours)));
    }

    if (depots.a_refaire > 0) {
        segments.push(
            depots.a_refaire === 1
                ? t('retenue', 'depot_recap_refuse_un', '1 refusé')
                : t('retenue', 'depot_recap_a_refaire', ':count refusés').replace(':count', String(depots.a_refaire)),
        );
    }

    return segments.length === 0 ? null : segments.join(' · ');
}

/** Le badge de la PÉRIODE, à côté de son libellé. */
export interface BadgePeriode {
    libelle: string;
    /** Variante du `<Badge>` de shadcn — `outline` quand `classe` porte la couleur. */
    variant: 'default' | 'secondary' | 'outline';
    classe: string;
    /** Infobulle : ce que le badge ne dit pas en trois mots. `null` ⇒ aucune. */
    aide: string | null;
}

/** Toutes les lignes de la déclaration — les cinq compteurs en couvrent chacune une fois. */
export function totalLignes(depots: CompteursDepots): number {
    return depots.acceptes + depots.en_cours + depots.a_refaire + depots.non_traduits + depots.sans_verdict;
}

/**
 * « Déclarée » — mais seulement quand la DGI l'a dit, ligne par ligne.
 *
 * ═════════════════════════════════════════════════════════════════════════════
 * LE STATUT DIT CE QUE LE MANAGER A FAIT, PAS CE QUE LA DGI A RÉPONDU
 * ═════════════════════════════════════════════════════════════════════════════
 * `statut = declaree` s'écrit au moment de l'ENVOI. Le badge « Déclarée »
 * s'affichait donc sur une période dont les fichiers étaient encore en analyse,
 * ou déjà refusés — une affirmation fiscale fausse, en gros, en tête d'écran.
 *
 * Il ne s'affiche plus que si CHAQUE ligne est acceptée — calculé sur les
 * compteurs NON FILTRÉS (`declaration.depots`), jamais sur les lignes visibles.
 * Sinon, et par ordre de ce qui demande une main :
 *   1. un refus ⇒ « Refus à corriger », en rouge. Il passe AVANT l'attente : un
 *      envoi en attente se réglera seul, un refus jamais ;
 *   2. un envoi en attente ⇒ « Envoyée — en attente DGI », en amber ;
 *   3. ni l'un ni l'autre, mais des lignes sans verdict (jamais envoyées, code
 *      non traduit, certificat annulé) ⇒ « Déclarée — verdicts incomplets »,
 *      neutre. Rien n'est en cours : dire « en attente » serait faux, dire
 *      « Déclarée » tout court aussi.
 *
 * Les périodes en brouillon ou clôturées gardent leur badge : ce qu'elles
 * affirment (« on y travaille », « montants figés ») ne dépend pas de la DGI.
 */
export function badgePeriode(declaration: Pick<RetenueDeclaration, 'statut' | 'depots'>, t: Traducteur): BadgePeriode {
    const statut = (variant: BadgePeriode['variant']): BadgePeriode => ({
        libelle: t('retenue', `statut_${declaration.statut}`),
        variant,
        classe: '',
        aide: null,
    });

    if (declaration.statut === 'brouillon') return statut('outline');
    if (declaration.statut === 'cloturee') return statut('secondary');

    // Garde de survie : sans compteurs, l'écran ne sait rien de la DGI — il
    // garde l'étiquette d'avant plutôt que d'en inventer une.
    const depots = declaration.depots;
    if (!depots) return statut('default');

    const total = totalLignes(depots);

    if (depots.acceptes === total) return statut('default');

    if (depots.a_refaire > 0) {
        return {
            libelle: t('retenue', 'periode_refus', 'Refus à corriger'),
            variant: 'outline',
            classe: 'border-red-300 bg-red-50 text-red-800',
            aide: t(
                'retenue',
                'periode_refus_aide',
                ':count ligne(s) refusée(s) par la DGI ou jamais reçue(s) : ces retenues ne sont pas déclarées.',
            ).replace(':count', String(depots.a_refaire)),
        };
    }

    if (depots.en_cours > 0) {
        return {
            libelle: t('retenue', 'periode_attente', 'Envoyée — en attente DGI'),
            variant: 'outline',
            classe: 'border-amber-300 bg-amber-50 text-amber-900',
            aide: t('retenue', 'periode_attente_aide', ':count envoi(s) attendent le verdict de la DGI.').replace(':count', String(depots.en_cours)),
        };
    }

    return {
        libelle: t('retenue', 'periode_incomplete', 'Déclarée — verdicts incomplets'),
        variant: 'outline',
        classe: 'text-muted-foreground',
        aide: t('retenue', 'periode_incomplete_aide', ':count ligne(s) sans verdict de la DGI.').replace(':count', String(total - depots.acceptes)),
    };
}

/**
 * Ce que le menu d'une ligne a le droit de proposer — calculé ici, pas dans le JSX.
 *
 * ═════════════════════════════════════════════════════════════════════════════
 * « MODIFIER » ET « SUPPRIMER » SUIVENT `figee`, PLUS `tej_uuid`
 * ═════════════════════════════════════════════════════════════════════════════
 * Une ligne dont l'envoi a expiré n'a pas de `tej_uuid`, alors que la DGI a
 * accepté — ou analyse encore — son fichier. Sur `tej_uuid === null`, le menu
 * proposait donc « Modifier » et « Supprimer » à côté d'un badge « Accepté par
 * la DGI » : la DGI gardait un certificat à 1 000, le manager en déclarait
 * 5 000, ou plus rien. `figee` est le verdict du serveur
 * (`RetenueLigne::figeeParLaDgi()`), qui refuse de toute façon.
 *
 * Le RAPPORT de la DGI exige la permission du dépôt (`can.declare`) : c'est
 * celle de sa route, et un lien vers un 403 est un piège.
 */
export function actionsDeLigne(
    ligne: Pick<RetenueLigne, 'figee' | 'tej_depot'>,
    can: Pick<RetenueCan, 'update' | 'delete' | 'declare'>,
): { modifiable: boolean; supprimable: boolean; rapportUrl: string | null } {
    const figee = ligne.figee === true;

    return {
        modifiable: can.update && !figee,
        supprimable: can.delete && !figee,
        rapportUrl: can.declare && ligne.tej_depot?.etat === 'a_refaire' ? ligne.tej_depot.rapport_url : null,
    };
}

/**
 * Ce que le dialogue de paiement fige — et ce qu'il laisse, pour ne jamais
 * fabriquer d'impasse.
 *
 * `paiement_fige` dit que la DGI détient le fichier : la date de paiement est
 * DÉCLARÉE. Deux nuances, et chacune fermait une impasse réelle :
 *  - « Payé » ne se fige que si la ligne l'est DÉJÀ. Envoyée avec une date
 *    FUTURE, elle reste « Non payé » (le dépôt ne marque pas payé un virement
 *    qui n'a pas eu lieu) ; figer la case cochée la faisait passer « Payé » au
 *    premier enregistrement de la banque, avant le virement ;
 *  - la date ne se fige que si elle EXISTE. Un certificat historique importé
 *    sans date doit pouvoir en recevoir une — le serveur l'accepte ; un champ
 *    grisé et vide réclamait une date impossible à saisir.
 */
export function verrousDuPaiement(ligne: Pick<RetenueLigne, 'paiement_fige' | 'est_paye' | 'date_paiement'> | null): {
    fige: boolean;
    payeFige: boolean;
    dateFigee: boolean;
} {
    const fige = ligne?.paiement_fige === true;

    return {
        fige,
        payeFige: fige && ligne?.est_paye === true,
        dateFigee: fige && !!ligne?.date_paiement,
    };
}

/**
 * La date de paiement qui PART avec le formulaire.
 *
 * Figée : la date déclarée, inchangée. Sinon, elle ne part que si la ligne est
 * payée — ou si la ligne est figée sans date (on renseigne ce qui manquait) :
 * décocher « Payé » laissait l'ancienne date dans le champ désactivé, et elle
 * partait avec `est_paye: false`.
 */
export function dateDePaiementAEnvoyer(
    ligne: Pick<RetenueLigne, 'paiement_fige' | 'est_paye' | 'date_paiement'>,
    saisie: { est_paye: boolean; date_paiement: string },
): string | null {
    const { fige, dateFigee } = verrousDuPaiement(ligne);

    if (dateFigee) return ligne.date_paiement;

    return fige || saisie.est_paye ? saisie.date_paiement || null : null;
}
