/**
 * Journal d'activité — logique PURE de l'écran (aucun import React).
 *
 * Séparée des composants pour une raison : le harnais Vitest du dépôt tourne
 * en environnement `node` et ne collecte que `*.test.ts`. Tout ce qui décide
 * de quelque chose — la couleur d'une action, le regroupement par jour, ce qui
 * part dans l'URL — se teste donc ici, sans monter React.
 *
 * Les TEXTES, eux, restent dans les `.tsx` : `ActivityTranslationsTest` ne
 * scanne que ces fichiers pour vérifier que chaque clé `t('activity', …)`
 * existe en français et en anglais.
 */
import { localDayKey } from '@/lib/relative-time';
import type {
    ActivityChange,
    ActivityEntry,
    ActivityEvent,
    ActivityFilters,
    ActivityModule,
    ActivitySource,
    ActivitySubject,
    ActivityTeamState,
} from '@/types/activity';

/** Signature de `t()` de `useTranslation`, pour les fonctions qui la reçoivent. */
export type Translate = (namespace: string, key: string, fallback?: string) => string;

/* ─────────────────────────────────────────────────────────────────────────
 * Couleur = sens. Trois tons sémantiques au plus, plus le neutre.
 * ───────────────────────────────────────────────────────────────────────── */

export type ActivityTone = 'created' | 'deleted' | 'sensitive' | 'neutral';

/**
 * Création → vert, suppression → rouge, geste SENSIBLE → ambre (droits
 * modifiés, échec de connexion). Tout le reste — modification, restauration,
 * connexion, déconnexion — est neutre : c'est l'ordinaire du journal, le
 * colorer noierait les trois signaux qui comptent.
 */
export function eventTone(event: ActivityEvent): ActivityTone {
    switch (event) {
        case 'created':
            return 'created';
        case 'deleted':
            return 'deleted';
        case 'permissions_updated':
        case 'login_failed':
            return 'sensitive';
        default:
            return 'neutral';
    }
}

/** Classes des pastilles — les tons bruts déjà employés ailleurs dans l'app. */
export const TONE_CHIP_CLASSES: Record<ActivityTone, string> = {
    created: 'border-transparent bg-emerald-100 text-emerald-700',
    deleted: 'border-transparent bg-red-100 text-red-700',
    sensitive: 'border-transparent bg-amber-100 text-amber-800',
    neutral: 'border-border bg-muted text-muted-foreground',
};

/** Pastilles d'état de l'onglet Équipe : même grammaire de couleurs. */
export const TEAM_STATE_CHIP_CLASSES: Record<ActivityTeamState, string> = {
    active: 'border-transparent bg-emerald-100 text-emerald-700',
    quiet: 'border-transparent bg-amber-100 text-amber-800',
    inactive: 'border-border bg-muted text-muted-foreground',
    // Aucune trace depuis le début du suivi : pointillés, comme un filtre vide — rien n'est « posé ».
    never: 'border-dashed border-muted-foreground/40 bg-transparent text-muted-foreground',
};

/** Les connexions n'ont pas d'« élément » à décrire : leur phrase est différente. */
export function isSessionEvent(event: ActivityEvent): boolean {
    return event === 'login' || event === 'logout' || event === 'login_failed';
}

/**
 * Actions sans élément à montrer dans le panneau de détail : les connexions et
 * l'export du journal (ce qui est exporté, c'est le journal lui-même).
 */
export function hasNoSubjectBlock(event: ActivityEvent): boolean {
    return event === 'login' || event === 'logout' || event === 'exported';
}

/* ─────────────────────────────────────────────────────────────────────────
 * Origine d'une action — jamais le nom d'un prestataire à l'écran.
 * ───────────────────────────────────────────────────────────────────────── */

export type SourceKind = 'web' | 'webhook' | 'sync' | 'console' | 'admin' | 'other';

/** `web` | `webhook` | `sync` | `console` | `admin-*` → famille affichable. */
export function sourceKind(source: ActivitySource | null | undefined): SourceKind {
    if (source === 'web' || source === 'webhook' || source === 'sync' || source === 'console') {
        return source;
    }
    if (typeof source === 'string' && source.startsWith('admin-')) {
        return 'admin';
    }

    return 'other';
}

/* ─────────────────────────────────────────────────────────────────────────
 * Lignes du flux
 * ───────────────────────────────────────────────────────────────────────── */

export interface ActivityDayGroup {
    /** `aaaa-mm-jj`, jour calendaire LOCAL du navigateur. */
    key: string;
    /** Date ISO de la première ligne du groupe — sert à rendre l'en-tête. */
    firstIso: string;
    entries: ActivityEntry[];
}

/**
 * Regroupe les lignes par jour calendaire local, EN GARDANT l'ordre reçu du
 * serveur (le plus récent en haut). Deux lignes du même jour séparées par une
 * autre journée — impossible dans un flux trié, mais pas interdit par le
 * contrat — donnent deux groupes plutôt qu'un réordonnancement silencieux.
 */
export function groupByDay(entries: ActivityEntry[]): ActivityDayGroup[] {
    const groups: ActivityDayGroup[] = [];

    for (const entry of entries) {
        const key = localDayKey(entry.created_at);
        const last = groups[groups.length - 1];

        if (last && last.key === key) {
            last.entries.push(entry);
        } else {
            groups.push({ key, firstIso: entry.created_at, entries: [entry] });
        }
    }

    return groups;
}

/** Libellé d'un élément, ou son numéro quand le serveur n'a rien trouvé. */
export function subjectLabel(subject: ActivitySubject): string {
    return subject.label ?? `#${subject.id}`;
}

/** Clé d'identité d'un élément (même classe + même id = même historique). */
export function subjectKey(subject: ActivitySubject): string {
    return `${subject.type}:${subject.id}`;
}

/** Champs modifiés au-delà des pastilles affichées (« +3 »). */
export function extraFieldCount(entry: ActivityEntry): number {
    return Math.max(0, entry.summary_count - entry.summary_fields.length);
}

/**
 * Ce que la ligne montre sous sa phrase : l'aperçu lisible du serveur quand il
 * existe (« Statut : Confirmée → Annulée »), sinon les seuls noms de champs
 * modifiés (ancien affichage, gardé en repli), sinon rien.
 */
export function rowDetail(entry: Pick<ActivityEntry, 'preview' | 'summary_fields' | 'summary_count'>): 'preview' | 'fields' | 'none' {
    if (entry.preview) return 'preview';
    if (entry.summary_fields.length > 0 || entry.summary_count > 0) return 'fields';

    return 'none';
}

/** Lignes d'audit regroupées sous la principale (« +2 actions liées »). */
export function relatedCount(entry: ActivityEntry): number {
    return Math.max(0, entry.records.length - 1);
}

/** Sépare ce qu'un humain lit des champs techniques, repliés par défaut. */
export function splitChanges(changes: ActivityChange[]): { visible: ActivityChange[]; technical: ActivityChange[] } {
    return {
        visible: changes.filter((change) => !change.technical),
        technical: changes.filter((change) => change.technical),
    };
}

/** L'historique du plus récent au plus ancien, quel que soit l'ordre reçu. */
export function sortNewestFirst(entries: ActivityEntry[]): ActivityEntry[] {
    return [...entries].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
}

/* ─────────────────────────────────────────────────────────────────────────
 * Texte
 * ───────────────────────────────────────────────────────────────────────── */

/** Remplace les jetons `:nom` d'un libellé traduit. Un jeton inconnu reste tel quel. */
export function fill(template: string, vars: Record<string, string | number>): string {
    return template.replace(/:([a-zA-Z_]+)/g, (match, name: string) => (name in vars ? String(vars[name]) : match));
}

/** 12345 → « 12 345 » (fr) / « 12,345 » (en). */
export function formatCount(value: number, locale: string): string {
    return new Intl.NumberFormat(locale.toLowerCase().startsWith('en') ? 'en-US' : 'fr-FR').format(value);
}

/* ─────────────────────────────────────────────────────────────────────────
 * Filtres ⇄ URL
 * ───────────────────────────────────────────────────────────────────────── */

/**
 * L'état des filtres tel que l'écran le manipule. `from` / `to` à null =
 * « la fenêtre par défaut du serveur » : on ne l'envoie pas, il la choisit.
 */
export interface ActivityQuery {
    tab: 'feed' | 'team';
    user: string | null;
    module: ActivityModule | null;
    event: ActivityEvent | null;
    from: string | null;
    to: string | null;
    search: string | null;
    system: boolean;
    franchise: string | null;
    /** Un seul élément (« Reservation:15509 ») — posé par les boutons « Historique » des fiches. */
    subject: string | null;
    /** Seulement les gestes sensibles (suppressions, droits, échecs de connexion). */
    sensitive: boolean;
}

export function queryFromFilters(filters: ActivityFilters): ActivityQuery {
    return {
        tab: filters.tab === 'team' ? 'team' : 'feed',
        user: filters.user || null,
        module: filters.module || null,
        event: filters.event || null,
        from: filters.from || null,
        to: filters.to || null,
        search: filters.search || null,
        system: filters.system === true,
        franchise: filters.franchise || null,
        subject: filters.subject || null,
        sensitive: filters.sensitive === true,
    };
}

/**
 * Ce qui part dans l'URL. Les valeurs vides sont OMISES — l'adresse ne porte
 * que ce que l'utilisateur a choisi, et reste lisible quand on la partage.
 * `tab=feed` est la valeur par défaut, `system` ne s'écrit que coché.
 */
export function queryToParams(query: ActivityQuery): Record<string, string> {
    const params: Record<string, string> = {};

    if (query.tab === 'team') params.tab = 'team';
    if (query.user) params.user = query.user;
    if (query.module) params.module = query.module;
    if (query.event) params.event = query.event;
    if (query.from) params.from = query.from;
    if (query.to) params.to = query.to;
    if (query.search && query.search.trim() !== '') params.search = query.search.trim();
    if (query.system) params.system = '1';
    if (query.franchise) params.franchise = query.franchise;
    if (query.subject) params.subject = query.subject;
    if (query.sensitive) params.sensitive = '1';

    return params;
}

/**
 * Paramètres de l'export : EXACTEMENT les filtres du flux affiché. L'onglet
 * n'y figure jamais — on n'exporte que le flux, jamais le tableau d'équipe.
 */
export function exportParams(query: ActivityQuery): Record<string, string> {
    return queryToParams({ ...query, tab: 'feed' });
}

/** Tout ce que « Tout effacer » remet à zéro — l'onglet, lui, reste. */
export const CLEARED_FILTERS: Omit<ActivityQuery, 'tab'> = {
    user: null,
    module: null,
    event: null,
    from: null,
    to: null,
    search: null,
    system: false,
    franchise: null,
    subject: null,
    sensitive: false,
};

/**
 * Les filtres qui n'ont de sens que sur le FLUX. On les retire en passant à
 * l'onglet Équipe : ses chiffres ne doivent dépendre que de ce qui est affiché
 * au-dessus d'eux (la période et la franchise).
 */
export const FEED_ONLY_CLEARED: Pick<ActivityQuery, 'user' | 'module' | 'event' | 'search' | 'system' | 'subject' | 'sensitive'> = {
    user: null,
    module: null,
    event: null,
    search: null,
    system: false,
    subject: null,
    sensitive: false,
};

const MODULES: ReadonlySet<string> = new Set<ActivityModule>([
    'reservations',
    'logements',
    'clients',
    'finances',
    'crm',
    'equipe',
    'parametres',
    'connexions',
    'autre',
]);

const EVENTS: ReadonlySet<string> = new Set<ActivityEvent>([
    'created',
    'updated',
    'deleted',
    'restored',
    'login',
    'logout',
    'login_failed',
    'permissions_updated',
    'exported',
]);

/** Une valeur de liste déroulante (chaîne) est-elle un module du contrat ? */
export function isActivityModule(value: string): value is ActivityModule {
    return MODULES.has(value);
}

/** Une valeur de liste déroulante (chaîne) est-elle une action du contrat ? */
export function isActivityEvent(value: string): value is ActivityEvent {
    return EVENTS.has(value);
}

/* ─────────────────────────────────────────────────────────────────────────
 * Période : préréglages
 * ───────────────────────────────────────────────────────────────────────── */

export type PeriodPreset = 'today' | 'yesterday' | 'last7' | 'last30' | 'this_month';

/**
 * Ordre d'affichage ET ordre de reconnaissance : quand deux préréglages
 * couvrent les mêmes jours (« Ce mois-ci » le 1er = « Aujourd'hui », le 7 =
 * « 7 derniers jours »), c'est le premier de la liste qui nomme la période.
 */
export const PERIOD_PRESETS: readonly PeriodPreset[] = ['today', 'yesterday', 'last7', 'last30', 'this_month'];

/**
 * La période que le serveur applique quand l'URL n'en porte pas
 * (`activity.default_range_days` = 7 : aujourd'hui et les six jours d'avant).
 */
export const DEFAULT_PRESET: PeriodPreset = 'last7';

export interface DayRange {
    /** `aaaa-mm-jj`, bornes incluses. */
    from: string;
    to: string;
}

/** Jour local décalé de `days` jours (constructeur local : jamais de glissement UTC). */
function shiftDay(now: Date, days: number): Date {
    return new Date(now.getFullYear(), now.getMonth(), now.getDate() + days);
}

/** Les jours couverts par un préréglage, vus depuis `now` (jour LOCAL). */
export function presetRange(preset: PeriodPreset, now: Date): DayRange {
    const today = dateToIsoDay(shiftDay(now, 0));

    switch (preset) {
        case 'today':
            return { from: today, to: today };
        case 'yesterday': {
            const yesterday = dateToIsoDay(shiftDay(now, -1));
            return { from: yesterday, to: yesterday };
        }
        case 'last7':
            return { from: dateToIsoDay(shiftDay(now, -6)), to: today };
        case 'last30':
            return { from: dateToIsoDay(shiftDay(now, -29)), to: today };
        case 'this_month':
            return { from: dateToIsoDay(new Date(now.getFullYear(), now.getMonth(), 1)), to: today };
    }
}

/** Le préréglage qui couvre exactement `from` → `to`, ou null (dates précises). */
export function detectPreset(from: string, to: string, now: Date): PeriodPreset | null {
    return PERIOD_PRESETS.find((preset) => {
        const range = presetRange(preset, now);
        return range.from === from && range.to === to;
    }) ?? null;
}

/**
 * La période RÉELLEMENT appliquée. `from`/`to` à null = l'URL n'en porte
 * pas : c'est la fenêtre par défaut du serveur, et c'est elle qu'on affiche —
 * jamais « Choisir une période » sur une liste déjà filtrée.
 */
export function effectiveRange(query: Pick<ActivityQuery, 'from' | 'to'>, now: Date): DayRange {
    if (query.from && query.to) {
        return { from: query.from, to: query.to };
    }

    return presetRange(DEFAULT_PRESET, now);
}

/** La période diffère-t-elle de la fenêtre par défaut ? */
export function isDefaultPeriod(query: Pick<ActivityQuery, 'from' | 'to'>, now: Date): boolean {
    const { from, to } = effectiveRange(query, now);

    return detectPreset(from, to, now) === DEFAULT_PRESET;
}

/** `2026-09-23` → `23/09`, ou `23/09/2025` hors de l'année en cours (une date sans année serait ambiguë). */
export function formatShortDay(isoDay: string, now: Date): string {
    const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDay);
    if (!match) return isoDay;

    return Number(match[1]) === now.getFullYear() ? `${match[3]}/${match[2]}` : `${match[3]}/${match[2]}/${match[1]}`;
}

/**
 * Nombre de critères qui RESTREIGNENT la vue — le « (n) » de « Tout effacer ».
 * La période compte pour un dès qu'elle n'est plus la fenêtre par défaut.
 */
export function activeFilterCount(query: ActivityQuery, now: Date): number {
    const narrowing = [
        query.user !== null,
        query.event !== null,
        query.franchise !== null,
        Boolean(query.search && query.search.trim()),
        query.system,
        query.module !== null,
        query.subject !== null,
        query.sensitive,
        !isDefaultPeriod(query, now),
    ];

    return narrowing.filter(Boolean).length;
}

/** `2026-09-23` → Date locale à minuit ; `undefined` si illisible. */
export function isoDayToDate(value: string | null | undefined): Date | undefined {
    const match = typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) : null;
    if (!match) return undefined;

    const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));

    return Number.isNaN(date.getTime()) ? undefined : date;
}

/** Date locale → `2026-09-23` (jamais `toISOString()`, qui passe en UTC et recule d'un jour). */
export function dateToIsoDay(date: Date): string {
    return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
