/**
 * Comparatif d'envoi « Push maintenant » — logique PURE (aucun réseau, aucun DOM).
 *
 * ═══════════════════════════════════════════════════════════════════════════
 * POURQUOI CE FICHIER EXISTE
 * ═══════════════════════════════════════════════════════════════════════════
 * L'écran de confirmation est le DERNIER endroit où un humain peut arrêter un
 * envoi de prix vers Rentals United — donc vers des annonces Airbnb / Booking
 * vivantes. Tout ce qu'il affiche (le nombre de nuits, l'écart, la fourchette,
 * l'état de la sauvegarde) doit donc être calculé par du code testable, pas
 * déduit au vol dans le rendu.
 *
 * Deux leçons déjà payées dans ce dossier gouvernent ce fichier :
 *
 *  1. `api-errors.ts` — on ne suppose JAMAIS la forme du corps renvoyé. Ici
 *     `normalizePushPreview()` accepte n'importe quoi et rend toujours une
 *     structure complète : une clé manquante devient « — », jamais « 0 »
 *     (afficher 0 $ là où Rentals United ne porte aucun prix ferait croire à
 *     une nuit bradée, et pousserait quelqu'un à valider un envoi à tort).
 *
 *  2. Le résumé est reconstruit à partir des nuits quand le serveur ne l'envoie
 *     pas — plutôt qu'un en-tête vide au-dessus d'une liste pleine, qui laisse
 *     l'utilisateur compter les lignes à la main.
 *
 * Devise : les montants viennent de Rentals United en USD et sont affichés
 * BRUTS, jamais convertis (`properties.current_price` en TND est un autre
 * chiffre, il ne doit pas se mélanger à celui-ci).
 */
import { format, parseISO } from 'date-fns';
import type { PushBackupState, PushPreviewNight, PushPreviewResponse, PushPreviewSummary, RestoreOutcome } from '../types';

/** Sens d'un écart de prix, pour la couleur de la colonne « Écart ». */
export type DeltaTone = 'up' | 'down' | 'flat' | 'unknown';

/** Un nombre exploitable, ou `null` — jamais `NaN`, jamais `0` par défaut. */
export function toFiniteOrNull(value: unknown): number | null {
    if (typeof value === 'number') return Number.isFinite(value) ? value : null;
    if (typeof value === 'string' && value.trim() !== '') {
        const n = Number(value);
        return Number.isFinite(n) ? n : null;
    }
    return null;
}

/** Une nuit du comparatif, ou `null` si l'entrée est inexploitable. */
function normalizeNight(raw: unknown): PushPreviewNight | null {
    if (raw == null || typeof raw !== 'object') return null;
    const brut = raw as Record<string, unknown>;

    const date = typeof brut.date === 'string' ? brut.date.trim() : '';
    const proposed = toFiniteOrNull(brut.proposed);
    // Sans date ni prix proposé, la ligne n'a rien à comparer : on la retire
    // plutôt que d'afficher une rangée vide au milieu du tableau.
    if (date === '' || proposed == null) return null;

    return {
        date,
        current: toFiniteOrNull(brut.current),
        proposed,
        delta_pct: toFiniteOrNull(brut.delta_pct),
        is_booked: brut.is_booked === true,
    };
}

/** Les nuits exploitables du corps renvoyé, dans l'ordre reçu. */
export function normalizeNights(raw: unknown): PushPreviewNight[] {
    if (!Array.isArray(raw)) return [];
    const nuits: PushPreviewNight[] = [];
    for (const entree of raw) {
        const nuit = normalizeNight(entree);
        if (nuit) nuits.push(nuit);
    }
    return nuits;
}

/** Moyenne des écarts connus, arrondie à l'entier ; `null` si aucun n'est connu. */
function moyenneEcart(nuits: PushPreviewNight[]): number | null {
    const ecarts = nuits.map((n) => n.delta_pct).filter((v): v is number => v != null);
    if (ecarts.length === 0) return null;
    return Math.round(ecarts.reduce((total, v) => total + v, 0) / ecarts.length);
}

/**
 * Résumé RECALCULÉ à partir des nuits envoyables (les réservées ne bougent pas,
 * elles ne comptent donc ni dans la fourchette ni dans la moyenne).
 * Sert de repli quand le serveur n'a pas fourni de résumé exploitable.
 */
export function deriveSummary(nuits: PushPreviewNight[], currency: string): PushPreviewSummary {
    const envoyables = nuits.filter((n) => !n.is_booked);
    const proposes = envoyables.map((n) => n.proposed);

    return {
        count: envoyables.length,
        booked_skipped: nuits.length - envoyables.length,
        min_proposed: proposes.length > 0 ? Math.min(...proposes) : null,
        max_proposed: proposes.length > 0 ? Math.max(...proposes) : null,
        avg_delta_pct: moyenneEcart(envoyables),
        currency,
        nights_without_current_price: envoyables.filter((n) => n.current == null).length,
    };
}

/** Devise par défaut : Rentals United rend ses prix en USD. */
export const DEFAULT_CURRENCY = 'USD';

/**
 * Résumé du serveur, complété nuit par nuit pour chaque champ absent.
 * Un champ fourni fait foi (c'est le serveur qui connaît les gardes du
 * préflight) ; un champ manquant est recalculé au lieu d'afficher un zéro.
 */
export function normalizeSummary(raw: unknown, nuits: PushPreviewNight[]): PushPreviewSummary {
    const brut = raw != null && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
    const devise = typeof brut.currency === 'string' && brut.currency.trim() !== '' ? brut.currency.trim() : DEFAULT_CURRENCY;
    const derive = deriveSummary(nuits, devise);

    return {
        count: toFiniteOrNull(brut.count) ?? derive.count,
        booked_skipped: toFiniteOrNull(brut.booked_skipped) ?? derive.booked_skipped,
        min_proposed: toFiniteOrNull(brut.min_proposed) ?? derive.min_proposed,
        max_proposed: toFiniteOrNull(brut.max_proposed) ?? derive.max_proposed,
        avg_delta_pct: toFiniteOrNull(brut.avg_delta_pct) ?? derive.avg_delta_pct,
        currency: devise,
        nights_without_current_price: toFiniteOrNull(brut.nights_without_current_price) ?? derive.nights_without_current_price,
    };
}

/**
 * État de la sauvegarde annuelle.
 *
 * `exists` n'est vrai que si le serveur le dit EXPLICITEMENT : c'est la phrase
 * qui rassure sur la réversibilité, elle ne doit jamais être affirmée par
 * défaut. Dans le doute, on annonce « une photo sera prise » — ce qui est de
 * toute façon ce que fait le serveur si la ligne n'existe pas encore.
 */
export function normalizeBackup(raw: unknown): PushBackupState {
    const brut = raw != null && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
    return {
        exists: brut.exists === true,
        captured_at: chaineOuNull(brut.captured_at),
        nights: toFiniteOrNull(brut.nights) ?? 0,
        restore_outcome: normalizeRestoreOutcome(brut.restore_outcome),
        restore_from: chaineOuNull(brut.restore_from),
        restore_until: chaineOuNull(brut.restore_until),
        restore_nights: toFiniteOrNull(brut.restore_nights) ?? 0,
        restore_without_origin: toFiniteOrNull(brut.restore_without_origin) ?? 0,
    };
}

/** Une chaîne non vide, ou `null`. */
function chaineOuNull(value: unknown): string | null {
    return typeof value === 'string' && value.trim() !== '' ? value : null;
}

const RESTORE_OUTCOMES: readonly RestoreOutcome[] = ['restore', 'no_origin', 'nothing_pushed', 'window_past'];

/** Issue de restauration connue, ou `null` — jamais une valeur inventée. */
export function normalizeRestoreOutcome(value: unknown): RestoreOutcome | null {
    return typeof value === 'string' && (RESTORE_OUTCOMES as readonly string[]).includes(value) ? (value as RestoreOutcome) : null;
}

/** Corps complet de `yield.push.preview`, quelle que soit sa forme d'arrivée. */
export function normalizePushPreview(payload: unknown): PushPreviewResponse {
    const brut = payload != null && typeof payload === 'object' ? (payload as Record<string, unknown>) : {};
    const nights = normalizeNights(brut.nights);
    return {
        nights,
        summary: normalizeSummary(brut.summary, nights),
        backup: normalizeBackup(brut.backup),
    };
}

/** Sens de l'écart : hausse, baisse, inchangé, ou inconnu (pas de prix actuel). */
export function deltaTone(deltaPct: number | null): DeltaTone {
    if (deltaPct == null) return 'unknown';
    if (deltaPct > 0) return 'up';
    if (deltaPct < 0) return 'down';
    return 'flat';
}

/** Symbole affiché après le montant (style français : « 42 $ »). */
const SYMBOLES: Record<string, string> = { USD: '$', EUR: '€', GBP: '£' };

/** Le tiret cadratin utilisé partout pour « valeur inconnue ». */
export const ABSENT = '—';

/**
 * Montant Rentals United, BRUT (aucune conversion). `null` devient « — » :
 * une nuit sans prix chez Rentals United n'est PAS une nuit à 0 $.
 */
export function formatMoney(value: number | null, currency: string = DEFAULT_CURRENCY): string {
    if (value == null) return ABSENT;
    const arrondi = Math.round(value * 100) / 100;
    const symbole = SYMBOLES[currency] ?? currency;
    return `${arrondi} ${symbole}`;
}

/** Écart en pourcentage, signé. `0` devient « = », `null` devient « — ». */
export function formatDeltaPct(deltaPct: number | null): string {
    if (deltaPct == null) return ABSENT;
    const arrondi = Math.round(deltaPct);
    if (arrondi === 0) return '=';
    return `${arrondi > 0 ? '+' : ''}${arrondi} %`;
}

/** Gabarits du résumé, fournis traduits par l'appelant. */
export interface SummaryLabels {
    /** « :count nuits » */
    nights: string;
    /** « de :min à :max » */
    range: string;
    /** « :count nuits réservées ne bougent pas » */
    booked: string;
    /** « :pct en moyenne » */
    average: string;
    /** « :count nuits sans prix actuel » */
    withoutCurrent: string;
}

function remplace(gabarit: string, valeurs: Record<string, string>): string {
    return Object.entries(valeurs).reduce((texte, [cle, valeur]) => texte.split(`:${cle}`).join(valeur), gabarit);
}

/**
 * Les fragments du résumé de tête, dans l'ordre de lecture. L'appelant les
 * joint par « · ». Un fragment sans information (aucune nuit réservée, pas de
 * fourchette calculable) est OMIS plutôt qu'affiché à zéro : le résumé doit se
 * lire d'un coup d'œil, pas s'éplucher.
 */
export function buildSummaryFragments(summary: PushPreviewSummary, labels: SummaryLabels): string[] {
    const fragments: string[] = [remplace(labels.nights, { count: String(summary.count) })];

    if (summary.min_proposed != null && summary.max_proposed != null) {
        fragments.push(
            remplace(labels.range, {
                min: formatMoney(summary.min_proposed, summary.currency),
                max: formatMoney(summary.max_proposed, summary.currency),
            }),
        );
    }

    if (summary.booked_skipped > 0) {
        fragments.push(remplace(labels.booked, { count: String(summary.booked_skipped) }));
    }

    if (summary.avg_delta_pct != null) {
        fragments.push(remplace(labels.average, { pct: formatDeltaPct(summary.avg_delta_pct) }));
    }

    if (summary.nights_without_current_price > 0) {
        fragments.push(remplace(labels.withoutCurrent, { count: String(summary.nights_without_current_price) }));
    }

    return fragments;
}

/** Les trois motifs qui éteignent le bouton « Push maintenant ». */
export interface PushBlockLabels {
    noConfig: string;
    inactive: string;
    readOnly: string;
}

/** La forme minimale de ligne que la disponibilité du push regarde. */
export interface PushRowState {
    id: number | null;
    has_config: boolean;
    is_active: boolean;
}

/**
 * Le POURQUOI on ne peut pas OUVRIR le comparatif, ou `null` s'il est ouvrable.
 *
 * ═══════════════════════════════════════════════════════════════════════════
 * POURQUOI LE YIELD ÉTEINT N'EMPÊCHE PAS D'OUVRIR
 * ═══════════════════════════════════════════════════════════════════════════
 * « Revenir aux prix d'avant » ne vit QU'À L'INTÉRIEUR de cette fenêtre. Si le
 * yield éteint fermait aussi la porte, la séquence la plus naturelle du monde
 * — je pousse, le résultat ne me plaît pas, j'éteins le yield — rendrait la
 * restauration DÉFINITIVEMENT inatteignable depuis l'écran, alors que la photo
 * est là et que le serveur, lui, l'accepte (`YieldPriceRestoreService` ne
 * regarde pas `is_active`, à dessein).
 *
 * Le yield éteint bloque donc l'ENVOI (cf. `pushSendBlockedReason`), jamais la
 * consultation ni le retour en arrière.
 *
 * Ordre volontaire : d'abord ce qui tient au logement, ensuite ce qui tient à
 * l'utilisateur — un contrôle éteint doit dire ce qui manque ICI avant de
 * parler des droits.
 */
export function pushOpenBlockedReason(row: PushRowState, canManage: boolean, labels: PushBlockLabels): string | null {
    if (!row.has_config || row.id == null) return labels.noConfig;
    if (!canManage) return labels.readOnly;
    return null;
}

/**
 * Le POURQUOI on ne peut pas ENVOYER depuis le comparatif, ou `null`.
 *
 * Séparé de l'ouverture : la fenêtre reste consultable et le retour en arrière
 * reste possible sur un logement dont le yield est éteint.
 */
export function pushSendBlockedReason(row: PushRowState, labels: PushBlockLabels): string | null {
    if (!row.has_config || row.id == null) return labels.noConfig;
    if (!row.is_active) return labels.inactive;
    return null;
}

/**
 * Date de prise de la photo annuelle, lisible. `null` si l'horodatage est
 * absent ou illisible — l'appelant se rabat alors sur une phrase sans date,
 * plutôt que d'afficher « Invalid Date ».
 */
export function formatCapturedAt(iso: string | null): string | null {
    if (!iso) return null;
    try {
        const date = parseISO(iso);
        if (Number.isNaN(date.getTime())) return null;
        return format(date, 'dd/MM/yyyy HH:mm');
    } catch {
        return null;
    }
}

/** Gabarits du résumé de restauration, fournis traduits par l'appelant. */
export interface RestoreLabels {
    /** « :count nuits, du :from au :until, photo du :date. … » */
    window: string;
    /** « :count nuits de cette période n’ont aucun prix d’origine … » */
    withoutOrigin: string;
    noOrigin: string;
    nothingPushed: string;
    windowPast: string;
    /** Repli quand une date est absente ou illisible. */
    unknownDate: string;
}

/** 'YYYY-MM-DD' → 'dd/MM/yyyy', ou `null` si absente ou illisible. */
export function formatDayIso(iso: string | null): string | null {
    if (!iso) return null;
    try {
        const date = parseISO(iso);
        if (Number.isNaN(date.getTime())) return null;
        return format(date, 'dd/MM/yyyy');
    } catch {
        return null;
    }
}

/**
 * Les lignes du résumé de l'étape « Revenir aux prix d'avant » : ce qui sera
 * réécrit (N nuits, du … au …, photo du …) et ce qui ne le sera pas.
 *
 * Rend `[]` quand l'aperçu n'est pas disponible (en cours, en échec, sans
 * photo ou d'un serveur plus ancien) : l'appelant affiche alors son propre
 * message — et surtout, NE BLOQUE PAS le bouton (cf. restoreSubmitBlocked) :
 * le serveur revalide tout avant d'écrire.
 */
export function buildRestoreSummary(backup: PushBackupState | null, labels: RestoreLabels): string[] {
    if (!backup?.exists || backup.restore_outcome == null) return [];

    switch (backup.restore_outcome) {
        case 'no_origin':
            return [labels.noOrigin];
        case 'nothing_pushed':
            return [labels.nothingPushed];
        case 'window_past':
            return [labels.windowPast];
        case 'restore': {
            const lignes = [
                remplace(labels.window, {
                    count: String(backup.restore_nights),
                    from: formatDayIso(backup.restore_from) ?? labels.unknownDate,
                    until: formatDayIso(backup.restore_until) ?? labels.unknownDate,
                    date: formatCapturedAt(backup.captured_at) ?? labels.unknownDate,
                }),
            ];
            if (backup.restore_without_origin > 0) {
                lignes.push(remplace(labels.withoutOrigin, { count: String(backup.restore_without_origin) }));
            }
            return lignes;
        }
    }
}

/**
 * Le bouton de restauration n'est éteint QUE le temps d'un geste en cours —
 * l'envoi lui-même, ou le chargement de l'aperçu. JAMAIS parce que l'aperçu a
 * échoué : sinon un comparatif en panne (calculateur en erreur, Rentals United
 * muet) rendrait la marche arrière inatteignable, au moment précis où l'on en
 * a besoin. Le serveur revalide de toute façon photo, fenêtre et périmètre.
 *
 * `previewError` fait partie du contrat À DESSEIN, bien qu'il ne compte pas :
 * l'état « aperçu en échec » passe par cette fonction, testée, plutôt que de
 * pouvoir être ajouté en douce au `disabled` du bouton.
 */
export function restoreSubmitBlocked(state: { sending: boolean; loading: boolean; previewError: string | null }): boolean {
    return state.sending || state.loading;
}

/**
 * La version À JOUR d'une ligne gardée en mémoire par une fenêtre ouverte.
 *
 * La fenêtre d'envoi reçoit une COPIE de la ligne, prise au clic. Après une
 * restauration en échec, le serveur a pourtant déjà éteint le yield
 * (`is_active=false`) et la redirection Inertia a rafraîchi `rows` — mais pas
 * cette copie : l'étape affichait encore « Le yield sera d'abord éteint… » à
 * côté du message « Le yield reste éteint », et « Envoyer » restait actif au
 * retour. On relit donc la ligne dans `rows` par son uid, et on garde la copie
 * si elle en a disparu (filtre, logement retiré).
 */
export function latestRow<T extends { uid: string }>(rows: readonly T[], row: T | null): T | null {
    if (row == null) return null;
    return rows.find((candidate) => candidate.uid === row.uid) ?? row;
}

/** Date d'une nuit, en tête de ligne du comparatif (« lun. 03 sept. »). */
export function formatNightDate(iso: string): string {
    try {
        const date = parseISO(iso);
        if (Number.isNaN(date.getTime())) return iso;
        return format(date, 'EEE dd MMM');
    } catch {
        return iso;
    }
}

/** Le message flash d'une redirection, une fois lu. */
export type FlashKind = 'success' | 'error';

export interface FlashMessage {
    type: FlashKind;
    message: string;
}

/**
 * Lit le message flash d'une réponse Inertia.
 *
 * L'envoi et la restauration répondent par une REDIRECTION : le verdict du
 * serveur (« 87 nuits envoyées », ou le motif d'un refus du préflight) n'arrive
 * pas dans un corps JSON mais dans ce flash. Un refus renvoyé en 200 doit donc
 * être reconnu comme tel — sans quoi une garde qui a bloqué l'envoi
 * s'afficherait en vert, ce qui est le pire message possible sur cet écran.
 *
 * Deux formes acceptées, parce que l'application en produit deux :
 *   • `{ type: 'success' | 'error', message: '…' }` — la forme du contrôleur ;
 *   • `{ success: '…' }` / `{ error: '…' }` — la forme Laravel courante.
 *
 * Rend `null` quand il n'y a rien de lisible : l'appelant se rabat alors sur
 * son propre libellé.
 */
export function readFlash(props: unknown): FlashMessage | null {
    if (props == null || typeof props !== 'object') return null;

    const flash = (props as { flash?: unknown }).flash;
    if (flash == null || typeof flash !== 'object') return null;
    const brut = flash as Record<string, unknown>;

    const message = typeof brut.message === 'string' ? brut.message.trim() : '';
    if (message !== '') {
        return { type: brut.type === 'error' ? 'error' : 'success', message };
    }

    // L'erreur est testée AVANT le succès : une réponse qui porterait les deux
    // doit se lire comme un échec, jamais l'inverse.
    const erreur = typeof brut.error === 'string' ? brut.error.trim() : '';
    if (erreur !== '') return { type: 'error', message: erreur };

    const succes = typeof brut.success === 'string' ? brut.success.trim() : '';
    if (succes !== '') return { type: 'success', message: succes };

    return null;
}
