/**
 * Table OBA (ajustement par remplissage) — logique PURE de l'éditeur, testée
 * par oba-rules.test.ts. Le composant (components/rules/ObaEditor.tsx)
 * appelle ces fonctions telles quelles : c'est le seul moyen de tester son
 * comportement sans DOM.
 *
 * ═══════════════════════════════════════════════════════════════════════════
 * LE CONTRAT (le même que le serveur, YieldRuleGroup::obaRulesProblems)
 * ═══════════════════════════════════════════════════════════════════════════
 *  - chaque échéance COMMENCE par une ligne « Dès 0 % » : pas de zone
 *    implicite sous le premier palier ;
 *  - le remplissage ne fait que MONTER les prix : aucun multiplicateur sous
 *    ×1, et la courbe ne baisse jamais quand l'occupation monte. Les baisses
 *    viennent de la règle dernière minute ;
 *  - deux seuils d'une échéance ne portent pas le même taux.
 *
 * ═══════════════════════════════════════════════════════════════════════════
 * POURQUOI LE TRI SE FAIT SUR LA VALEUR — ET QUAND
 * ═══════════════════════════════════════════════════════════════════════════
 * L'éditeur modifie et supprime un seuil par son INDEX dans la valeur. Trier
 * seulement l'affichage ferait toucher un autre seuil que celui qu'on voit ;
 * trier à chaque frappe ferait sauter la ligne pendant la saisie (taper 35
 * passe par 3). La valeur est donc triée à des moments choisis :
 *  - à l'ajout et à la suppression d'un seuil — le geste est terminé, le
 *    suivant partira du nouvel ordre ;
 *  - quand le focus QUITTE la carte de l'échéance (focusLeavesCard). Pas
 *    quand il quitte le champ « taux » : Tab vers le multiplicateur de la
 *    même ligne, ou clic sur sa corbeille, arriveraient APRÈS le tri, sur la
 *    ligne qui a pris la place — un autre seuil que celui qu'on voyait.
 */
import type { ObaRule, ObaThreshold } from '../types';
import { RULE_BOUNDS } from './rule-units';

/** Taux de la ligne verrouillée : chaque échéance commence à 0 %. */
export const OBA_FLOOR_OCC = 0;

/**
 * Multiplicateur plancher — le même que le `min` du champ multiplicateur
 * (RULE_BOUNDS, copie de config/yield.php → rule_bounds.oba_mult) : une
 * seule source, l'avertissement et l'indice de saisie ne divergent pas.
 */
export const OBA_FLOOR_MULT: number = RULE_BOUNDS.obaMult.min;

/**
 * Dernier jour qu'une échéance peut couvrir — le `max:365` du serveur
 * (YieldRuleSectionRules, oba_rules.*.window.*) : au-delà, la fenêtre ne
 * porte sur aucune nuit tarifée.
 */
export const OBA_WINDOW_MAX_DAY = 365;

/** Écart de taux proposé par « Ajouter un seuil ». */
export const OBA_THRESHOLD_STEP = 10;

/** Longueur (en jours) d'une échéance ajoutée. */
const NEW_WINDOW_DAYS = 15;

/** Défauts qu'une échéance peut porter, dans l'ordre où l'écran les dit. */
export type ObaIssue = 'floor_missing' | 'below_floor' | 'not_monotone' | 'duplicate_occ';

/**
 * Taux comparable : un taux illisible (NaN — champ vidé, cf. occFromField)
 * passe après tous les autres.
 */
const occKey = (threshold: ObaThreshold): number => (Number.isFinite(threshold.occ) ? threshold.occ : Number.POSITIVE_INFINITY);

/**
 * Texte du champ « taux » → taux de la valeur. Un champ VIDÉ donne NaN, pas
 * 0 : `Number('')` vaut 0, et un champ qu'on efface pour retaper deviendrait
 * un second « 0 % » (doublon de la ligne verrouillée, signalé aussitôt,
 * remonté sous elle au tri). NaN reste en place et part en dernier au tri ;
 * envoyé tel quel, il devient null en JSON et le serveur le refuse (taux
 * requis).
 */
export function occFromField(text: string): number {
    return text.trim() === '' ? Number.NaN : Number(text);
}

/** Taux de la valeur → texte du champ : un taux illisible s'affiche vide (jamais « NaN »). */
export function occToField(occ: number): number | '' {
    return Number.isFinite(occ) ? occ : '';
}

/**
 * Seuils triés par taux croissant — tri STABLE : deux taux égaux gardent leur
 * ordre. Déjà triée, la table est rendue TELLE QUELLE (même objet) : quitter
 * un champ sans rien déplacer ne produit aucun changement de valeur, donc ni
 * formulaire modifié ni aperçu relancé.
 */
export function sortThresholds(rule: ObaRule): ObaRule {
    const sorted = [...rule.thresholds].sort((a, b) => occKey(a) - occKey(b));
    return sorted.every((threshold, i) => threshold === rule.thresholds[i]) ? rule : { ...rule, thresholds: sorted };
}

/**
 * La ligne `index` est-elle la ligne verrouillée « Dès 0 % » ? C'est la
 * PREMIÈRE ligne à 0 % : un second 0 % tapé ailleurs reste modifiable (et
 * signalé en doublon), au lieu de se figer sous le curseur.
 */
export function isFloorThreshold(rule: ObaRule, index: number): boolean {
    return rule.thresholds.findIndex((threshold) => threshold.occ === OBA_FLOOR_OCC) === index;
}

/**
 * Nouvelle échéance : à la suite de la dernière, sur 15 jours, avec sa seule
 * ligne « Dès 0 % → ×1 ». Bornée au jour 365 (au-delà, le serveur refuse) ;
 * `null` quand la dernière échéance finit déjà au jour 365 — il n'y a plus
 * de place, le bouton « Ajouter une échéance » est grisé.
 */
export function newWindow(rules: ObaRule[]): ObaRule | null {
    const lastTo = rules.reduce((max, rule) => (Number.isFinite(rule.window[1]) ? Math.max(max, rule.window[1]) : max), -1);
    const from = lastTo + 1;
    if (from > OBA_WINDOW_MAX_DAY) {
        return null;
    }
    return {
        window: [from, Math.min(OBA_WINDOW_MAX_DAY, lastTo + NEW_WINDOW_DAYS)],
        thresholds: [{ occ: OBA_FLOOR_OCC, mult: OBA_FLOOR_MULT }],
    };
}

/**
 * Seuil proposé par « Ajouter un seuil » : 10 points au-dessus du dernier.
 * Si la place est prise (le dernier est à 100 %, comme dans les presets), on
 * descend : 10 points au-dessus du seuil précédent, et ainsi de suite ; puis
 * au point près. `null` quand tous les taux de 0 à 100 sont pris.
 *
 * Son multiplicateur est celui du seuil existant JUSTE EN DESSOUS du taux
 * proposé — pas forcément celui dont on est parti : 85 % + 10 = 95 % se
 * range entre 90 % et 100 %, et prend le ×1,10 de 90 %, pas le ×1,05 de
 * 85 %, qui ferait baisser la courbe. Posé entre ses deux voisins au
 * multiplicateur du premier, le seuil garde monotone une courbe qui l'était.
 */
export function nextThreshold(rule: ObaRule): ObaThreshold | null {
    const sorted = sortThresholds(rule).thresholds.filter((threshold) => Number.isFinite(threshold.occ));
    const used = new Set(sorted.map((threshold) => threshold.occ));

    if (sorted.length === 0) {
        return { occ: OBA_FLOOR_OCC, mult: OBA_FLOOR_MULT };
    }

    for (const step of [OBA_THRESHOLD_STEP, 1]) {
        for (let i = sorted.length - 1; i >= 0; i--) {
            const occ = Math.min(100, sorted[i].occ + step);
            if (!used.has(occ)) {
                // Le taux proposé dépasse sorted[i] : son voisin du dessous existe.
                let below = sorted[i];
                for (const threshold of sorted) {
                    if (threshold.occ < occ) below = threshold;
                }
                return { occ, mult: below.mult };
            }
        }
    }

    return null;
}

/** La table après « Ajouter un seuil » : le seuil proposé, rangé à sa place (valeur triée). */
export function addThreshold(rule: ObaRule): ObaRule {
    const next = nextThreshold(rule);
    return next === null ? rule : sortThresholds({ ...rule, thresholds: [...rule.thresholds, next] });
}

/** Modifie le seuil `index` de la valeur — le même que celui affiché à cette ligne. */
export function patchThreshold(rule: ObaRule, index: number, patch: Partial<ObaThreshold>): ObaRule {
    return { ...rule, thresholds: rule.thresholds.map((threshold, i) => (i === index ? { ...threshold, ...patch } : threshold)) };
}

/**
 * Retire le seuil `index` — sauf la ligne « Dès 0 % », qui ne se supprime pas
 * — et rend le reste TRIÉ : la suppression termine le geste, c'est un moment
 * sûr pour ranger une ligne restée hors de sa place (taux modifié, focus
 * encore dans la carte).
 */
export function removeThreshold(rule: ObaRule, index: number): ObaRule {
    if (isFloorThreshold(rule, index)) {
        return rule;
    }
    return sortThresholds({ ...rule, thresholds: rule.thresholds.filter((_, i) => i !== index) });
}

/** Le multiplicateur ne baisse jamais quand le taux monte (lecture par taux croissant, comme le calculateur). */
export function isMonotone(rule: ObaRule): boolean {
    const sorted = sortThresholds(rule).thresholds;
    return sorted.every((threshold, i) => i === 0 || !(threshold.mult < sorted[i - 1].mult));
}

/** Ce qui ferait refuser l'échéance par le serveur — à dire AVANT l'enregistrement. */
export function obaRuleIssues(rule: ObaRule): ObaIssue[] {
    const issues: ObaIssue[] = [];
    const occs = rule.thresholds.map((threshold) => threshold.occ);

    // Taux lisibles seulement pour les doublons : deux champs vidés ne
    // « portent » pas le même taux — le serveur les refusera comme taux requis.
    const readable = occs.filter((occ) => Number.isFinite(occ));

    if (!occs.includes(OBA_FLOOR_OCC)) issues.push('floor_missing');
    if (rule.thresholds.some((threshold) => threshold.mult < OBA_FLOOR_MULT)) issues.push('below_floor');
    if (!isMonotone(rule)) issues.push('not_monotone');
    if (new Set(readable).size !== readable.length) issues.push('duplicate_occ');

    return issues;
}

/**
 * Le focus QUITTE-t-il la carte de l'échéance ? C'est le seul moment où la
 * valeur est triée (en plus de l'ajout et de la suppression).
 *
 *  - `next` (le relatedTarget du blur) est DANS la carte : Tab vers le
 *    multiplicateur, clic dans un autre champ de la même échéance → non.
 *    Trier ici ferait arriver la frappe (ou le clic) suivante sur la ligne
 *    qui a pris la place.
 *  - un bouton de la souris vient d'être enfoncé DANS la carte
 *    (`pressedInside`) → non plus. Safari et Firefox sous macOS ne donnent
 *    pas le focus à un bouton cliqué : le blur arrive avec `next` = null,
 *    comme une vraie sortie, alors que le clic va tomber sur la corbeille
 *    d'une ligne de cette carte.
 *  - sinon (Tab hors de la carte, clic ailleurs sur la page) → oui.
 */
export function focusLeavesCard(card: Pick<Node, 'contains'>, next: EventTarget | null, pressedInside: boolean): boolean {
    if (pressedInside) return false;
    return next === null || !card.contains(next as Node);
}
