/**
 * Fonctions PURES de l'écran « Automatisations » — testées dans
 * `automatisations-format.test.ts` (environnement `node`, sans rendu).
 *
 * Aucune horloge lue en dur : `now` se passe en argument.
 */
import { correspondARecherche } from '@/components/ui/select-search-match';
import type { AutomationTrigger, PropertyOption, RuleTemplate, SendRow, SendStatus, TemplateGroup } from '@/types/automatisations';

type Lang = 'fr' | 'en';

function lang(locale: string | null | undefined): Lang {
    return typeof locale === 'string' && locale.toLowerCase().startsWith('en') ? 'en' : 'fr';
}

function pad(value: number): string {
    return String(value).padStart(2, '0');
}

/**
 * Remplace `:cle` par sa valeur (même convention que les fichiers de langue
 * Laravel). Le mot ENTIER est lu : `:to` ne mord pas sur `:total`.
 */
export function fill(template: string, vars: Record<string, string | number>): string {
    return template.replace(/:([A-Za-z_]+)/g, (match, key: string) => (key in vars ? String(vars[key]) : match));
}

// ── Déclencheur lisible ────────────────────────────────────────────────────

export interface TriggerParts {
    trigger: AutomationTrigger;
    offsetDays: number;
    sendTime: string | null;
    delayMinutes: number;
}

/** « 30 min », « 2 h », « 1 h 30 ». */
export function formatDelay(minutes: number): string {
    const total = Math.max(0, Math.round(minutes));
    const hours = Math.floor(total / 60);
    const rest = total % 60;

    if (hours === 0) return `${rest} min`;
    if (rest === 0) return `${hours} h`;

    return `${hours} h ${pad(rest)}`;
}

function days(count: number, l: Lang): string {
    if (l === 'en') return `${count} ${count === 1 ? 'day' : 'days'}`;

    return `${count} ${count > 1 ? 'jours' : 'jour'}`;
}

/**
 * Le moment d'envoi d'une règle, en une phrase :
 * « 2 jours avant l'arrivée à 10:00 », « Le jour de l'arrivée à 14:00 »,
 * « 30 min après la réservation », « 1 jour après le départ à 11:00 ».
 *
 * Même lecture que le moteur (RuleSchedule) : « pendant le séjour » compte
 * ses jours depuis l'arrivée ; le jour de l'arrivée / du départ ignore le
 * décalage.
 */
export function describeTrigger(rule: TriggerParts, locale: string): string {
    const l = lang(locale);
    const n = Math.max(0, Math.round(rule.offsetDays));
    const at = rule.sendTime ? (l === 'en' ? ` at ${rule.sendTime}` : ` à ${rule.sendTime}`) : '';

    if (rule.trigger === 'booking_confirmed') {
        if (rule.delayMinutes <= 0) return l === 'en' ? 'Right after booking' : 'Dès la réservation';

        return l === 'en' ? `${formatDelay(rule.delayMinutes)} after booking` : `${formatDelay(rule.delayMinutes)} après la réservation`;
    }

    const fr: Record<Exclude<AutomationTrigger, 'booking_confirmed'>, string> = {
        before_checkin: `${days(n, 'fr')} avant l'arrivée`,
        checkin_day: "Le jour de l'arrivée",
        during_stay: `${days(n, 'fr')} après l'arrivée`,
        before_checkout: `${days(n, 'fr')} avant le départ`,
        checkout_day: 'Le jour du départ',
        after_checkout: `${days(n, 'fr')} après le départ`,
    };
    const en: Record<Exclude<AutomationTrigger, 'booking_confirmed'>, string> = {
        before_checkin: `${days(n, 'en')} before check-in`,
        checkin_day: 'On check-in day',
        during_stay: `${days(n, 'en')} after check-in`,
        before_checkout: `${days(n, 'en')} before check-out`,
        checkout_day: 'On check-out day',
        after_checkout: `${days(n, 'en')} after check-out`,
    };

    return (l === 'en' ? en : fr)[rule.trigger] + at;
}

/** Les déclencheurs qui lisent un décalage en jours (au moins 1). */
export const OFFSET_TRIGGERS: readonly AutomationTrigger[] = ['before_checkin', 'during_stay', 'before_checkout', 'after_checkout'];

export function usesOffset(trigger: AutomationTrigger): boolean {
    return OFFSET_TRIGGERS.includes(trigger);
}

export function usesSendTime(trigger: AutomationTrigger): boolean {
    return trigger !== 'booking_confirmed';
}

// ── Variables du texte ──────────────────────────────────────────────────────

/** Même motif que TemplateRenderer::TOKEN_PATTERN côté serveur. */
const TOKEN_PATTERN = /\{([^{}\s]+)\}/gu;

/** Les variables présentes dans un texte, sans doublon, dans l'ordre. */
export function tokensIn(text: string): string[] {
    const found: string[] = [];
    for (const match of text.matchAll(TOKEN_PATTERN)) {
        if (!found.includes(match[1])) found.push(match[1]);
    }

    return found;
}

/** Les variables du texte qui ne font pas partie de la liste connue. */
export function unknownTokens(text: string, known: readonly string[]): string[] {
    return tokensIn(text).filter((token) => !known.includes(token));
}

/**
 * Insère `insert` à la place de la sélection [start, end[ et rend la
 * nouvelle position du curseur (juste après l'insertion). Bornes hors texte
 * ramenées dans le texte ; start > end inversés.
 */
export function insertAtCursor(text: string, insert: string, start: number, end: number): { text: string; cursor: number } {
    const clamp = (value: number) => Math.min(Math.max(0, Number.isFinite(value) ? Math.trunc(value) : text.length), text.length);
    const from = Math.min(clamp(start), clamp(end));
    const to = Math.max(clamp(start), clamp(end));

    return { text: text.slice(0, from) + insert + text.slice(to), cursor: from + insert.length };
}

// ── Logements ───────────────────────────────────────────────────────────────

/**
 * Les logements qui répondent à la recherche, par nom OU nom interne — la même
 * règle que les autres sélecteurs de logement du Manager (SearchableSelect) :
 * accents ignorés, mots dans n'importe quel ordre.
 */
export function filterProperties(properties: PropertyOption[], search: string): PropertyOption[] {
    if (search.trim() === '') return properties;

    return properties.filter((property) => correspondARecherche({ name: property.name, internal_name: property.internalName }, search));
}

// ── Modèles ─────────────────────────────────────────────────────────────────

/** Ordre des groupes de la galerie : d'abord ce que Rentals United envoie aujourd'hui. */
const TEMPLATE_GROUPS: readonly TemplateGroup[] = ['rentals_united', 'landlord'];

/**
 * Les modèles rangés par groupe, groupes vides omis, ordre de la configuration
 * conservé dans chaque groupe.
 */
export function groupTemplates(templates: RuleTemplate[]): { group: TemplateGroup; templates: RuleTemplate[] }[] {
    return TEMPLATE_GROUPS.map((group) => ({
        group,
        templates: templates.filter((template) => (template.group === 'rentals_united' ? 'rentals_united' : 'landlord') === group),
    })).filter((section) => section.templates.length > 0);
}

// ── Dates ───────────────────────────────────────────────────────────────────

/** ISO → « 28/09/2026 14:05 » (fuseau du navigateur). Chaîne d'origine si illisible. */
export function formatDateTimeShort(iso: string | null | undefined): string {
    if (!iso) return '';
    const date = new Date(iso);
    if (Number.isNaN(date.getTime())) return iso;

    return `${pad(date.getDate())}/${pad(date.getMonth() + 1)}/${date.getFullYear()} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}

/**
 * Au-delà de ce délai sans battement de cœur, le planificateur est « en retard ».
 * Sur OVH il passe une fois par heure, à une minute choisie par l'hébergeur :
 * un intervalle et demi, pour ne pas crier au retard à chaque passage normal.
 */
export const STALE_AFTER_MINUTES = 90;

export type RunState = 'never' | 'stale' | 'fresh';

/** L'état du planificateur, lu sur son dernier passage. */
export function lastRunState(lastRunAt: string | null, now: Date, staleAfterMinutes: number = STALE_AFTER_MINUTES): RunState {
    if (!lastRunAt) return 'never';
    const at = new Date(lastRunAt);
    if (Number.isNaN(at.getTime())) return 'never';

    return now.getTime() - at.getTime() > staleAfterMinutes * 60_000 ? 'stale' : 'fresh';
}

/**
 * La base des liens que le planificateur met dans les messages (APP_URL), si
 * elle diffère de l'adresse de ce site — null quand elles concordent ou qu'on
 * ne peut pas les comparer. Le planificateur tourne en ligne de commande :
 * sans cette alerte, l'aperçu (adresse du navigateur) paraîtrait juste alors
 * que les messages partiraient avec un lien mort.
 */
export function linksBaseMismatch(linksBaseUrl: string | null, siteOrigin: string): string | null {
    if (!linksBaseUrl) return null;
    try {
        const base = new URL(linksBaseUrl).origin;
        const site = new URL(siteOrigin).origin;
        return base === site ? null : base;
    } catch {
        return null;
    }
}

// ── Statuts ─────────────────────────────────────────────────────────────────

/** La couleur d'un statut porte un sens, et un seul (légende affichée à l'écran). */
export type StatusTone = 'green' | 'amber' | 'gray' | 'red';

export function statusTone(status: SendStatus): StatusTone {
    switch (status) {
        case 'sent':
            return 'green';
        case 'pending_approval':
        case 'waiting_channel':
        case 'retry':
            return 'amber';
        case 'failed':
            return 'red';
        default:
            return 'gray';
    }
}

export const TONE_CLASSES: Record<StatusTone, string> = {
    green: 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900/50 dark:bg-emerald-950/40 dark:text-emerald-300',
    amber: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-300',
    gray: 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-800 dark:bg-gray-900/40 dark:text-gray-300',
    red: 'border-red-200 bg-red-50 text-red-800 dark:border-red-900/50 dark:bg-red-950/40 dark:text-red-300',
};

// ── Détail d'un envoi (historique) ──────────────────────────────────────────

/** « 28/09/2026 » et « 15:01 », dans le fuseau du navigateur ; null si illisible. */
export function dateAndTime(iso: string | null | undefined): { date: string; time: string } | null {
    if (!iso) return null;
    const value = new Date(iso);
    if (Number.isNaN(value.getTime())) return null;

    return {
        date: `${pad(value.getDate())}/${pad(value.getMonth() + 1)}/${value.getFullYear()}`,
        time: `${pad(value.getHours())}:${pad(value.getMinutes())}`,
    };
}

export type TimelineKind = 'scheduled' | 'prepared' | 'sent' | 'failed' | 'retry' | 'skipped' | 'cancelled' | 'pending' | 'waiting';

export interface TimelineStep {
    kind: TimelineKind;
    /** ISO ; null pour une étape encore en cours (à valider, fil attendu). */
    at: string | null;
    /** La couleur de l'étape finale ; null pour les étapes neutres. */
    tone: StatusTone | null;
}

/**
 * Le déroulé d'un envoi : seulement les étapes qui ont eu lieu, puis l'état
 * actuel. Pas d'étape « validé » séparée : la validation EST l'envoi — le
 * nom du valideur s'affiche sur l'étape « envoyé », sans heure inventée.
 */
export function sendTimeline(send: Pick<SendRow, 'status' | 'scheduledAt' | 'createdAt' | 'sentAt' | 'updatedAt'>): TimelineStep[] {
    const steps: TimelineStep[] = [];

    if (send.scheduledAt) steps.push({ kind: 'scheduled', at: send.scheduledAt, tone: null });
    if (send.createdAt) steps.push({ kind: 'prepared', at: send.createdAt, tone: null });

    const last: Record<SendStatus, Omit<TimelineStep, 'tone'>> = {
        sent: { kind: 'sent', at: send.sentAt ?? send.updatedAt },
        failed: { kind: 'failed', at: send.updatedAt },
        retry: { kind: 'retry', at: send.updatedAt },
        skipped: { kind: 'skipped', at: send.updatedAt },
        cancelled: { kind: 'cancelled', at: send.updatedAt },
        pending_approval: { kind: 'pending', at: null },
        waiting_channel: { kind: 'waiting', at: null },
    };

    steps.push({ ...last[send.status], tone: statusTone(send.status) });

    return steps;
}

/** Minutes entre l'heure prévue et l'envoi (0 = à l'heure) ; null si inconnu ou pas envoyé. */
export function sendDelayMinutes(send: Pick<SendRow, 'status' | 'scheduledAt' | 'sentAt'>): number | null {
    if (send.status !== 'sent' || !send.scheduledAt || !send.sentAt) return null;
    const scheduled = new Date(send.scheduledAt).getTime();
    const sent = new Date(send.sentAt).getTime();
    if (Number.isNaN(scheduled) || Number.isNaN(sent)) return null;

    return Math.max(0, Math.round((sent - scheduled) / 60_000));
}

/**
 * Le texte découpé en morceaux, les adresses web à part — pour les rendre
 * cliquables sans HTML. La ponctuation qui suit une adresse (point, virgule,
 * parenthèse fermante) reste dans le texte.
 */
export function linkParts(text: string): { text: string; href: string | null }[] {
    const parts: { text: string; href: string | null }[] = [];
    let rest = 0;

    for (const match of text.matchAll(/https?:\/\/[^\s<>"]+/g)) {
        const url = match[0].replace(/[.,;:!?)\]]+$/, '');
        const start = match.index ?? 0;
        if (start > rest) parts.push({ text: text.slice(rest, start), href: null });
        parts.push({ text: url, href: url });
        rest = start + url.length;
    }

    if (rest < text.length) parts.push({ text: text.slice(rest), href: null });

    return parts;
}

/**
 * Le motif dit-il que le résultat est INCONNU (délai dépassé, envoi
 * interrompu) ? Même lecture que ApprovalOutcome::isUnknown côté serveur :
 * le message a peut-être été reçu, il faut vérifier le fil avant de renvoyer.
 */
export function isUnknownResult(reason: string | null | undefined): boolean {
    return typeof reason === 'string' && reason.toLowerCase().includes('résultat inconnu');
}
