import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { useTranslation } from '@/hooks/use-translation';
import { cn } from '@/lib/utils';
import { router } from '@inertiajs/react';
import { format, parseISO } from 'date-fns';
import { Check, ChevronDown, GripVertical, Link2, RotateCcw, Send, Settings2 } from 'lucide-react';
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from 'react';
import { toast } from 'sonner';
import { usePinnedWidth } from '../hooks/usePinnedWidth';
import { useCanManageYield, useReadOnlyReason } from '../hooks/useYieldPermissions';
import { pushOpenBlockedReason } from '../lib/push-preview';
import type { AxisDay } from '../lib/day-axis';
import { buildAxis, decorateAxis } from '../lib/day-axis';
import {
    PINNED_CELL_CLASS,
    PINNED_FULL_WIDTH,
    PINNED_MIN_WIDTH,
    listingNames,
    pinnedCssVariables,
    pinnedDataAttributes,
    pinnedTourAnchors,
    type PinnedColumn,
} from '../lib/pinned-layout';
import type { PriceDay, YieldRuleGroup, YieldSeries, YieldTableRow } from '../types';
import { DateColumnHeader, DayCell, DisabledDayCell, SkeletonDayCell } from './DayGrid';
import HelpHint from './HelpHint';

const NS = 'yield.index';

/**
 * Géométrie des colonnes figées : voir `lib/pinned-layout.ts`. Chaque cellule
 * figée porte les classes fixes de `PINNED_CELL_CLASS[col]`, qui lisent leur
 * `left` et leur largeur dans des variables CSS posées sur le conteneur — la
 * barre de redimensionnement ne réécrit que ces variables.
 */

// Body pinned cells sit above day cells (z-20); header pinned cells sit at the
// top-left corner above everything (z-30); day header cells are z-10.
const PINNED_BODY = 'bg-background sticky z-20';
const PINNED_HEAD = 'bg-background sticky top-0 z-30';

/**
 * Shared day-cell tooltip state. ONE instance lives at the table level and is
 * driven by a single delegated mousemove listener that reads `data-*` from the
 * hovered cell — never threaded through the memoized rows/cells as a changing
 * prop, so hovering does NOT re-render any row. O(1) DOM regardless of cell count.
 */
interface CellTooltip {
    visible: boolean;
    x: number;
    y: number;
    iso: string;
    price: string;
    tnd: string;
    booked: boolean;
    reason: string;
}

const HIDDEN_TOOLTIP: CellTooltip = { visible: false, x: 0, y: 0, iso: '', price: '', tnd: '', booked: false, reason: '' };

/** Cursor offset + viewport clamp keeps the floating tooltip on-screen. */
const TOOLTIP_OFFSET = 14;
const TOOLTIP_MAX_W = 280;

interface YieldTableProps {
    rows: YieldTableRow[];
    exchangeRate: number;
    selected: Set<string>;
    series: YieldSeries;
    /** Tenant rule groups, for the per-row "assign to group" menu. */
    ruleGroups: YieldRuleGroup[];
    onToggleRow: (uid: string) => void;
    onSelectAll: () => void;
    onClearAll: () => void;
    /** Open the starting-rules dialog targeting a single (config-less) property. */
    onActivateSingle: (uid: string) => void;
    /** Open the rule slide-over for a configured row (by config id). */
    onConfigure: (configId: number) => void;
    /** Ouvre le comparatif d'envoi « Push maintenant » pour cette ligne. */
    onPushNow: (row: YieldTableRow) => void;
    /** Ouvre la confirmation de retour aux prix d'avant (photo existante). */
    onRestore: (row: YieldTableRow) => void;
    /**
     * Logements dont le libellé principal est partagé avec un autre — calculé
     * sur TOUT le parc par la page (voir `ambiguousListingUids`) : ils affichent
     * leur identifiant à toute largeur.
     */
    ambiguousNames: ReadonlySet<string>;
}

function OccupancyBadge({ pct }: { pct: number }) {
    // Brand-token thresholds — no coral. Low = muted, mid = default, high = stronger brand shade.
    let className = 'bg-muted text-muted-foreground';
    if (pct >= 40 && pct <= 75) {
        className = 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300';
    } else if (pct > 75) {
        className = 'bg-emerald-600 text-white';
    }
    return <span className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium', className)}>{pct}%</span>;
}

type PriceField = 'min_price' | 'base_price' | 'max_price';

/**
 * Inline-editable min/base/max band for a configured row. The three cells share
 * one local state object and post the FULL triple to yield.prices on blur, so
 * the server can validate the ordering (min ≤ base ≤ max) regardless of which
 * cell changed. A light client guard avoids a round-trip on an obviously broken
 * band.
 */
function usePriceBand(row: YieldTableRow, canManage: boolean) {
    const { t } = useTranslation();
    const [band, setBand] = useState<Record<PriceField, string>>({
        min_price: row.min_price != null ? String(row.min_price) : '',
        base_price: row.base_price != null ? String(row.base_price) : '',
        max_price: row.max_price != null ? String(row.max_price) : '',
    });

    const save = useCallback(() => {
        // Sans `yield.manage`, PATCH yield.prices répond 403 : les cellules sont
        // déjà en lecture seule, ce garde-fou ferme les chemins détournés.
        if (!canManage || row.id == null) return;
        const min = Number(band.min_price);
        const base = Number(band.base_price);
        const max = Number(band.max_price);
        if ([band.min_price, band.base_price, band.max_price].some((v) => v === '' || Number.isNaN(Number(v)))) return;

        if (min === row.min_price && base === row.base_price && max === row.max_price) return;

        if (min > base || base > max) {
            toast.error(t(NS, 'toast_band_order', 'Respecte l’ordre min ≤ base ≤ max.'));
            return;
        }

        router.patch(
            route('yield.prices', row.id),
            { min_price: min, base_price: base, max_price: max },
            {
                preserveScroll: true,
                preserveState: true,
                onSuccess: () => toast.success(t(NS, 'toast_prices_saved', 'Prix enregistrés.')),
                onError: () => toast.error(t(NS, 'toast_error', 'Une erreur est survenue.')),
            },
        );
    }, [band, canManage, row.id, row.min_price, row.base_price, row.max_price, t]);

    const setField = useCallback((field: PriceField, value: string) => setBand((prev) => ({ ...prev, [field]: value })), []);

    return { band, setField, save };
}

function PriceInputCell({
    row,
    field,
    value,
    onChange,
    onSave,
    col,
    canManage,
    readOnlyReason,
}: {
    row: YieldTableRow;
    field: PriceField;
    value: string;
    onChange: (field: PriceField, value: string) => void;
    onSave: () => void;
    /** Colonne figée occupée par ce champ (géométrie et masquage). */
    col: Extract<PinnedColumn, 'min' | 'base' | 'max'>;
    /** Sans `yield.manage`, la cellule devient une simple valeur affichée. */
    canManage: boolean;
    readOnlyReason: string;
}) {
    // Unconfigured rows have no band. The base column shows the live current price
    // as a hint; min/max stay blank.
    if (!row.has_config || row.id == null) {
        const fallback = field === 'base_price' && row.current_price != null ? `${Math.round(row.current_price)} TND` : '—';
        return (
            <td className={cn(PINNED_BODY, PINNED_CELL_CLASS[col], 'px-2')}>
                <span className="text-muted-foreground text-xs">{fallback}</span>
            </td>
        );
    }

    return (
        <td className={cn(PINNED_BODY, PINNED_CELL_CLASS[col], 'px-2')}>
            <Input
                type="number"
                min={0}
                value={value}
                disabled={!canManage}
                title={canManage ? undefined : readOnlyReason}
                onChange={(e) => onChange(field, e.target.value)}
                onBlur={onSave}
                onKeyDown={(e) => {
                    if (e.key === 'Enter') e.currentTarget.blur();
                }}
                className="h-8 w-full px-1.5 text-right text-xs"
            />
        </td>
    );
}

function StatusPill({ row }: { row: YieldTableRow }) {
    const { t } = useTranslation();
    if (row.is_active) {
        return (
            <span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
                {t(NS, 'status_active', 'Actif')}
            </span>
        );
    }
    if (row.has_config) {
        return <span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-[11px] font-medium">{t(NS, 'status_inactive', 'Inactif')}</span>;
    }
    return (
        <span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
            {t(NS, 'status_not_configured', 'Non configuré')}
        </span>
    );
}

/**
 * Per-row "assign to a rule group" control. A dropdown that links this single
 * logement to a group (or detaches it) in one click — POSTs yield.rule-groups.apply
 * for just this uid. Only configured rows can follow a group (a group carries no
 * prices), so an unconfigured row shows a dash.
 */
function RowGroupMenu({ row, groups, canManage, readOnlyReason }: { row: YieldTableRow; groups: YieldRuleGroup[]; canManage: boolean; readOnlyReason: string }) {
    const { t } = useTranslation();

    if (!row.has_config || row.id == null) {
        return <span className="text-muted-foreground text-xs">—</span>;
    }

    const assign = (groupId: number | null) => {
        if (!canManage) return;
        if (groupId === (row.rule_group_id ?? null)) return; // no-op
        router.post(
            route('yield.rule-groups.apply'),
            { rule_group_id: groupId, property_uids: [row.uid] },
            {
                preserveScroll: true,
                preserveState: true,
                onSuccess: () =>
                    toast.success(groupId == null ? t(NS, 'toast_detached', 'Logement détaché.') : t(NS, 'toast_group_applied', 'Groupe appliqué.')),
                onError: () => toast.error(t(NS, 'toast_error', 'Une erreur est survenue.')),
            },
        );
    };

    return (
        <DropdownMenu>
            <DropdownMenuTrigger asChild>
                <Button
                    variant="outline"
                    size="sm"
                    className="h-8 w-full justify-between gap-1 px-2 text-xs font-normal"
                    disabled={!canManage}
                    title={canManage ? undefined : readOnlyReason}
                >
                    <span className="flex min-w-0 items-center gap-1">
                        <Link2 className={cn('h-3.5 w-3.5 shrink-0', row.rule_group_name ? 'text-violet-600' : 'text-muted-foreground')} />
                        <span className={cn('truncate', !row.rule_group_name && 'text-muted-foreground')}>
                            {row.rule_group_name ?? t(NS, 'assign_group', 'Lier…')}
                        </span>
                    </span>
                    <ChevronDown className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
                </Button>
            </DropdownMenuTrigger>
            <DropdownMenuContent align="start" className="max-h-72 w-56 overflow-y-auto">
                <DropdownMenuItem onClick={() => assign(null)} disabled={row.rule_group_id == null} className="text-xs">
                    <Check className={cn('mr-2 h-3.5 w-3.5', row.rule_group_id == null ? 'opacity-100' : 'opacity-0')} />
                    {t(NS, 'group_none', 'Aucun (détacher)')}
                </DropdownMenuItem>
                {groups.length > 0 && <DropdownMenuSeparator />}
                {groups.length === 0 ? (
                    <DropdownMenuItem disabled className="text-muted-foreground text-xs">
                        {t(NS, 'group_empty', 'Aucun groupe — créez-en un d’abord.')}
                    </DropdownMenuItem>
                ) : (
                    groups.map((g) => (
                        <DropdownMenuItem key={g.id} onClick={() => assign(g.id)} className="text-xs">
                            <Check className={cn('mr-2 h-3.5 w-3.5', row.rule_group_id === g.id ? 'opacity-100' : 'opacity-0')} />
                            <span className="truncate">{g.name}</span>
                        </DropdownMenuItem>
                    ))
                )}
            </DropdownMenuContent>
        </DropdownMenu>
    );
}

interface RowViewProps {
    row: YieldTableRow;
    axis: AxisDay[];
    days: PriceDay[] | undefined;
    exchangeRate: number;
    isSelected: boolean;
    ruleGroups: YieldRuleGroup[];
    /** `yield.manage` — calculé une fois au niveau du tableau, passé en prop pour
        ne pas relire le contexte Inertia dans chacune des ~114 lignes mémoïsées. */
    canManage: boolean;
    readOnlyReason: string;
    onToggleRow: (uid: string) => void;
    onToggleYield: (row: YieldTableRow) => void;
    onConfigure: (configId: number) => void;
    onActivateSingle: (uid: string) => void;
    onPushNow: (row: YieldTableRow) => void;
    onRestore: (row: YieldTableRow) => void;
    /**
     * Le libellé principal de cette ligne est porté par un autre logement :
     * l'identifiant s'affiche en permanence. Un booléen, pas l'ensemble — une
     * prop qui changerait d'identité re-rendrait toutes les lignes.
     */
    ambiguousName: boolean;
    /**
     * Cette ligne porte les ancres de la visite guidée (interrupteur, envoi).
     * Une seule ligne à la fois — `document.querySelector` rend la première
     * ancre trouvée (TOURS.md §6.1) — et seulement quand la colonne yield est
     * affichée : une ancre dans une cellule masquée mesurerait 0 × 0 et la
     * visite surlignerait le coin de l'écran.
     */
    tourAnchors: boolean;
}

/**
 * Memoized table row. Re-renders only when its own props change (notably `days`
 * for live preview / lazy hydration), so editing one row's series doesn't
 * re-render the other ~113 rows. Every cell is vertically centered (default
 * table align), so the checkbox, name, switch, price inputs, occupancy and the
 * day prices all sit on one common horizontal centerline.
 */
const RowView = memo(function RowView({
    row,
    axis,
    days,
    exchangeRate,
    isSelected,
    ruleGroups,
    canManage,
    readOnlyReason,
    onToggleRow,
    onToggleYield,
    onConfigure,
    onActivateSingle,
    onPushNow,
    onRestore,
    ambiguousName,
    tourAnchors,
}: RowViewProps) {
    const { t } = useTranslation();
    const { band, setField, save } = usePriceBand(row, canManage);

    // Nom interne d'abord (celui de l'équipe), nom complet en seconde ligne —
    // et l'identifiant, toujours visible, si le libellé est porté par un autre.
    const names = listingNames(row, ambiguousName);

    /**
     * Le POURQUOI le bouton « Push maintenant » est éteint (ou `null` s'il est
     * utilisable). OUVRIR le comparatif suppose une configuration et le droit
     * d'écriture. Le yield éteint, lui, n'éteint PAS le bouton : la fenêtre est
     * aussi le chemin vers « Revenir aux prix d'avant », et c'est l'ENVOI qui y
     * est bloqué (voir `pushOpenBlockedReason` / `pushSendBlockedReason`).
     */
    const pushBlocked = pushOpenBlockedReason(row, canManage, {
        noConfig: t(NS, 'push_blocked_no_config', 'Ce logement n’a pas encore de configuration yield.'),
        inactive: t(NS, 'push_blocked_inactive', 'Le yield est éteint pour ce logement.'),
        readOnly: readOnlyReason,
    });

    // Le ⚙️ d'une ligne CONFIGURÉE ouvre le slide-over, qui reste consultable en
    // lecture seule : c'est le seul endroit où lire les règles d'un logement.
    // Sur une ligne NON configurée, il ouvre l'activation — une écriture.
    const configureIsWrite = !row.has_config || row.id == null;
    const configureDisabled = configureIsWrite && !canManage;

    // Map this row's series by date for O(1) per-cell lookup against the axis.
    const byDate = useMemo(() => {
        if (!days) return null;
        const m = new Map<string, PriceDay>();
        for (const d of days) m.set(d.date, d);
        return m;
    }, [days]);

    return (
        <tr data-state={isSelected ? 'selected' : undefined} className="hover:bg-muted/30 border-b transition-colors">
            <td className={cn(PINNED_BODY, PINNED_CELL_CLASS.select, 'px-2')}>
                <Checkbox checked={isSelected} onCheckedChange={() => onToggleRow(row.uid)} aria-label={`Sélectionner ${row.name}`} />
            </td>

            <td className={cn(PINNED_BODY, PINNED_CELL_CLASS.listing, 'px-2 py-2')}>
                <div className="flex items-center justify-between gap-1">
                    <div className="flex min-w-0 flex-col gap-0.5">
                        <span className="text-foreground truncate text-sm font-medium" title={row.name}>
                            {names.primary}
                        </span>
                        {/* Libellé partagé avec un autre logement : l'identifiant
                            (puis le nom complet) reste affiché à TOUTE largeur —
                            sans lui, deux lignes identiques porteraient chacune
                            un interrupteur et des prix actifs. */}
                        {names.disambiguation && (
                            <span className="text-muted-foreground truncate text-[11px] tabular-nums" title={row.name}>
                                {names.disambiguation}
                            </span>
                        )}
                        {/* Sinon, le nom complet en petit, seulement s'il y a un nom
                            interne ET la place de le lire : la bascule est un
                            sélecteur CSS sur le conteneur (`data-name-detail`),
                            pas une prop — élargir la zone ne re-rend aucune ligne. */}
                        {names.secondary && (
                            <span className="text-muted-foreground hidden truncate text-[11px] group-data-[name-detail]/yt:block" title={row.name}>
                                {names.secondary}
                            </span>
                        )}
                        <div className="mt-0.5 flex flex-wrap items-center gap-1">
                            <StatusPill row={row} />
                        </div>
                    </div>
                    <Button
                        type="button"
                        variant="outline"
                        size="icon"
                        disabled={configureDisabled}
                        onClick={() => (row.has_config && row.id != null ? onConfigure(row.id) : onActivateSingle(row.uid))}
                        aria-label={configureDisabled ? readOnlyReason : t(NS, 'configure', 'Configurer')}
                        title={
                            configureDisabled
                                ? readOnlyReason
                                : configureIsWrite
                                  ? t(NS, 'activate', 'Activer')
                                  : canManage
                                    ? t(NS, 'configure', 'Configurer')
                                    : t(NS, 'view_config', 'Consulter la configuration (lecture seule)')
                        }
                        className="h-8 w-8 shrink-0 border hover:bg-accent hover:text-accent-foreground"
                    >
                        <Settings2 className="h-4 w-4" />
                    </Button>
                </div>
            </td>

            <td className={cn(PINNED_BODY, PINNED_CELL_CLASS.yield, 'px-2')}>
                <div className="flex items-center gap-1.5">
                    {/* Le `title` vit sur l'enveloppe : un Switch désactivé n'émet
                        pas d'événement de survol, l'infobulle ne s'afficherait pas. */}
                    <span title={canManage ? undefined : readOnlyReason} data-tour={tourAnchors ? 'yield-row-switch' : undefined}>
                        <Switch checked={row.is_active} disabled={!canManage} onCheckedChange={() => onToggleYield(row)} />
                    </span>

                    {/* Envoi manuel vers Rentals United, juste à côté de
                        l'interrupteur : même enveloppe `title`, un bouton
                        désactivé n'émet pas non plus d'événement de survol. */}
                    <span title={pushBlocked ?? t(NS, 'push_now', 'Push maintenant')} data-tour={tourAnchors ? 'yield-row-push' : undefined}>
                        <Button
                            type="button"
                            variant="outline"
                            size="icon"
                            disabled={pushBlocked !== null}
                            onClick={() => onPushNow(row)}
                            aria-label={pushBlocked ?? t(NS, 'push_now', 'Push maintenant')}
                            className="hover:bg-accent hover:text-accent-foreground h-8 w-8 shrink-0 border"
                        >
                            <Send className="h-4 w-4" />
                        </Button>
                    </span>

                    {/*
                      * Le retour en arrière vit ICI, sur la ligne, et pas seulement
                      * au fond de la fenêtre d'envoi : c'est le geste qu'on cherche
                      * quand on n'aime pas ce qui vient de partir, et l'enfouir
                      * derrière un bouton « Envoyer » revient à le cacher au moment
                      * précis où l'on en a besoin. Il n'apparaît que si une photo
                      * existe — donc uniquement quand il y a quelque chose à rendre.
                      */}
                    {row.has_backup && canManage && (
                        <span title={t(NS, 'restore_open', 'Revenir aux prix d’avant')}>
                            <Button
                                type="button"
                                variant="outline"
                                size="icon"
                                onClick={() => onRestore(row)}
                                aria-label={t(NS, 'restore_open', 'Revenir aux prix d’avant')}
                                className="hover:bg-accent hover:text-accent-foreground h-8 w-8 shrink-0 border border-amber-300 text-amber-700 dark:border-amber-800 dark:text-amber-400"
                            >
                                <RotateCcw className="h-4 w-4" />
                            </Button>
                        </span>
                    )}
                </div>
            </td>

            <PriceInputCell row={row} field="min_price" value={band.min_price} onChange={setField} onSave={save} col="min" canManage={canManage} readOnlyReason={readOnlyReason} />
            <PriceInputCell row={row} field="base_price" value={band.base_price} onChange={setField} onSave={save} col="base" canManage={canManage} readOnlyReason={readOnlyReason} />
            <PriceInputCell row={row} field="max_price" value={band.max_price} onChange={setField} onSave={save} col="max" canManage={canManage} readOnlyReason={readOnlyReason} />

            <td className={cn(PINNED_BODY, PINNED_CELL_CLASS.occupancy, 'px-2')}>
                <OccupancyBadge pct={row.occupancy_pct} />
            </td>

            {/* Plus de `border-r` ici : la colonne groupe peut disparaître, et
                le bord de la zone figée est dessiné par la barre elle-même. */}
            <td className={cn(PINNED_BODY, PINNED_CELL_CLASS.group, 'px-2')}>
                <RowGroupMenu row={row} groups={ruleGroups} canManage={canManage} readOnlyReason={readOnlyReason} />
            </td>

            {axis.map((d) => {
                if (!row.has_config) {
                    return <DisabledDayCell key={d.iso} weekendTint={d.isWeekend} todayTint={d.isToday} />;
                }
                if (!byDate) {
                    return <SkeletonDayCell key={d.iso} weekendTint={d.isWeekend} todayTint={d.isToday} />;
                }
                const day = byDate.get(d.iso);
                return (
                    <DayCell
                        key={d.iso}
                        day={day}
                        basePrice={row.base_price}
                        exchangeRate={exchangeRate}
                        weekendTint={d.isWeekend}
                        todayTint={d.isToday}
                    />
                );
            })}
        </tr>
    );
});

/** A pinned header cell with an optional help bubble. */
function HeadCell({ label, col, help, tourAnchor }: { label: string; col: PinnedColumn; help?: string; tourAnchor?: string }) {
    return (
        <th className={cn(PINNED_HEAD, PINNED_CELL_CLASS[col], 'text-muted-foreground px-2 py-2 text-left font-medium')} data-tour={tourAnchor}>
            <span className="inline-flex items-center whitespace-nowrap">
                {label}
                {help && <HelpHint text={help} />}
            </span>
        </th>
    );
}

export default function YieldTable({
    rows,
    exchangeRate,
    selected,
    series,
    ruleGroups,
    onToggleRow,
    onSelectAll,
    onClearAll,
    onActivateSingle,
    onConfigure,
    onPushNow,
    onRestore,
    ambiguousNames,
}: YieldTableProps) {
    const { t, locale } = useTranslation();
    const canManage = useCanManageYield();
    const readOnlyReason = useReadOnlyReason();

    const allFilteredSelected = rows.length > 0 && rows.every((r) => selected.has(r.uid));

    // Shared date axis from the union of loaded series (falls back to today..+89).
    const axis = useMemo(() => decorateAxis(buildAxis(Object.values(series)), locale), [series, locale]);

    // ONE shared tooltip for all ~114×90 day cells. A single delegated listener
    // on the scroll container reads the hovered cell's data-* and positions it
    // near the cursor with no delay; nothing is threaded into the memoized rows.
    const scrollRef = useRef<HTMLDivElement>(null);
    const [tooltip, setTooltip] = useState<CellTooltip>(HIDDEN_TOOLTIP);

    const handleGridMouseMove = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
        const cell = (e.target as HTMLElement).closest<HTMLElement>('[data-iso]');
        if (!cell) {
            setTooltip((prev) => (prev.visible ? HIDDEN_TOOLTIP : prev));
            return;
        }
        const iso = cell.dataset.iso ?? '';
        const price = cell.dataset.price ?? '';
        const tnd = cell.dataset.tnd ?? '';
        const booked = cell.dataset.booked === '1';
        const reason = cell.dataset.reason ?? '';
        // Clamp to the viewport so the tooltip never spills off the right/bottom edge.
        const x = Math.min(e.clientX + TOOLTIP_OFFSET, window.innerWidth - TOOLTIP_MAX_W - 8);
        const y = Math.min(e.clientY + TOOLTIP_OFFSET, window.innerHeight - 8);
        setTooltip({ visible: true, x, y, iso, price, tnd, booked, reason });
    }, []);

    const handleGridMouseLeave = useCallback(() => setTooltip((prev) => (prev.visible ? HIDDEN_TOOLTIP : prev)), []);

    const tooltipDate = useMemo(() => {
        if (!tooltip.iso) return '';
        try {
            return format(parseISO(tooltip.iso), 'EEE dd MMM yyyy');
        } catch {
            return tooltip.iso;
        }
    }, [tooltip.iso]);

    /*
     * `t` change d'identité à CHAQUE rendu : `useTranslation()` redéfinit sa
     * fonction à chaque appel. Placé dans les dépendances, il recréait
     * `handleToggleYield` à chaque rendu du tableau — et comme c'est une prop
     * de `RowView`, le `memo` ne servait plus à rien : chaque mouvement de
     * souris sur la grille (l'infobulle re-rend le tableau) et chaque
     * validation de la barre re-rendaient les ~121 lignes (mesuré : 121 × 4
     * appels de useTranslation par survol). Le rappel lit donc la dernière
     * `t` dans une ref, et reste stable.
     */
    const tRef = useRef(t);
    useEffect(() => {
        tRef.current = t;
    });

    const handleToggleYield = useCallback(
        (row: YieldTableRow) => {
            if (!canManage) return;
            if (!row.has_config || row.id == null) {
                onActivateSingle(row.uid);
                return;
            }
            router.post(
                route('yield.toggle', row.id),
                {},
                {
                    preserveScroll: true,
                    preserveState: true,
                    onSuccess: () =>
                        toast.success(
                            row.is_active
                                ? tRef.current(NS, 'toast_toggled_off', 'Yield désactivé.')
                                : tRef.current(NS, 'toast_toggled_on', 'Yield activé.'),
                        ),
                    onError: () => toast.error(tRef.current(NS, 'toast_error', 'Une erreur est survenue.')),
                },
            );
        },
        [canManage, onActivateSingle],
    );

    const dayColCount = axis.length;

    // Barre de redimensionnement de la zone figée. `layout` est la disposition
    // VALIDÉE ; pendant un glissement, le hook écrit directement dans le DOM
    // (voir usePinnedWidth) et ce composant ne se re-rend pas.
    const emptyCellRef = useRef<HTMLTableCellElement>(null);
    const { layout, zoneRef, separatorRef, separatorHandlers } = usePinnedWidth({ emptyCellRef, trailingColumns: dayColCount });

    // Mêmes valeurs que celles que le glissement écrit à la main : au
    // relâchement, React les réécrit à l'identique. Mémoïsées pour que le
    // survol de la grille (qui re-rend ce composant pour l'infobulle) ne
    // reconstruise rien.
    const zoneStyle = useMemo(() => pinnedCssVariables(layout) as CSSProperties, [layout]);
    const zoneData = useMemo(() => pinnedDataAttributes(layout), [layout]);

    // Ancres de la visite qui dépendent de la disposition (première ligne,
    // en-tête Min) : retirées quand leur colonne est masquée — voir pinnedTourAnchors.
    const tourAnchors = pinnedTourAnchors(layout, rows);

    return (
        <>
            {/*
              * `group/yt` : les cellules figées lisent sur ce conteneur leurs
              * variables de géométrie et leurs `data-hide-*`. La barre vit ICI,
              * hors du conteneur qui défile : elle reste au bord de la zone figée
              * quand la grille des jours défile horizontalement.
              */}
            <div ref={zoneRef} style={zoneStyle} {...zoneData} className="group/yt relative">
                <div ref={scrollRef} onMouseMove={handleGridMouseMove} onMouseLeave={handleGridMouseLeave} className="overflow-x-auto rounded-xl border">
                    <table className="w-full border-collapse text-sm">
                        <thead>
                            <tr>
                                <th className={cn(PINNED_HEAD, PINNED_CELL_CLASS.select, 'px-2 py-2 text-left')}>
                                    <Checkbox
                                        checked={allFilteredSelected}
                                        onCheckedChange={() => (allFilteredSelected ? onClearAll() : onSelectAll())}
                                        aria-label="Tout sélectionner"
                                    />
                                </th>
                                <HeadCell label={t(NS, 'col_listing', 'Logement')} col="listing" help={t(NS, 'col_listing_help', 'Le logement, son nom interne et son statut yield. L’icône ⚙️ ouvre la configuration détaillée.')} />
                                <HeadCell label={t(NS, 'col_yield', 'Yield')} col="yield" help={t(NS, 'col_yield_help', 'Active ou désactive la tarification dynamique pour ce logement. Désactivé = le prix n’est plus piloté automatiquement. Le bouton en forme d’avion en papier envoie les 90 prochains jours vers Rentals United, après confirmation.')} />
                                {/* Ancre de l'étape « bande de prix » sur Min, la dernière des
                                    trois à disparaître ; retirée si la colonne est masquée. */}
                                <HeadCell label={t(NS, 'col_min', 'Min')} col="min" help={t(NS, 'col_min_help', 'Prix plancher : le calcul ne descend jamais en dessous. En USD.')} tourAnchor={tourAnchors.band ? 'yield-band' : undefined} />
                                <HeadCell label={t(NS, 'col_base', 'Base')} col="base" help={t(NS, 'col_base_help', 'Tarif de référence d’une nuit normale, point de départ de tous les ajustements. En USD.')} />
                                <HeadCell label={t(NS, 'col_max', 'Max')} col="max" help={t(NS, 'col_max_help', 'Prix plafond : le calcul ne dépasse jamais ce montant. En USD.')} />
                                <HeadCell label={t(NS, 'col_occupancy', 'Occupation (7 j)')} col="occupancy" help={t(NS, 'col_occupancy_help', 'Part des nuits vendables des 7 prochains jours déjà vendues — la mesure qu’utilise l’OBA. Séjours propriétaires, demandes non acceptées et blocages ne sont pas vendables ; une semaine sans nuit vendable affiche 0 %.')} />
                                <HeadCell label={t(NS, 'col_group', 'Groupe')} col="group" help={t(NS, 'col_group_help', 'Lie ce logement à un groupe de règles : il suit alors automatiquement ses saisons / last-minute / OBA. « Lier… » pour choisir, « Aucun » pour détacher.')} />
                                {axis.map((d, i) => (
                                    <DateColumnHeader key={d.iso} day={d} tourAnchor={i === 0 ? 'yield-calendar' : undefined} />
                                ))}
                            </tr>
                        </thead>
                        <tbody>
                            {rows.length === 0 ? (
                                <tr>
                                    {/* Le colSpan suit les colonnes figées RENDUES : un
                                        colSpan trop grand ajouterait des colonnes fantômes
                                        au modèle du tableau. */}
                                    <td ref={emptyCellRef} colSpan={layout.visibleCount + dayColCount} className="text-muted-foreground py-10 text-center">
                                        {t(NS, 'empty', 'Aucun logement trouvé.')}
                                    </td>
                                </tr>
                            ) : (
                                rows.map((row) => (
                                    <RowView
                                        key={row.uid}
                                        row={row}
                                        axis={axis}
                                        days={series[row.uid]}
                                        exchangeRate={exchangeRate}
                                        isSelected={selected.has(row.uid)}
                                        ruleGroups={ruleGroups}
                                        canManage={canManage}
                                        readOnlyReason={readOnlyReason}
                                        onToggleRow={onToggleRow}
                                        onToggleYield={handleToggleYield}
                                        onConfigure={onConfigure}
                                        onActivateSingle={onActivateSingle}
                                        onPushNow={onPushNow}
                                        onRestore={onRestore}
                                        ambiguousName={ambiguousNames.has(row.uid)}
                                        tourAnchors={row.uid === tourAnchors.rowUid}
                                    />
                                ))
                            )}
                        </tbody>
                    </table>
                </div>

                {/*
                  * La barre : posée sur le bord droit de la zone figée (+1 px : la
                  * bordure du conteneur), sur toute la hauteur du tableau. Zone de
                  * prise de 16 px pour le doigt, trait de 1 px au repos qui sert
                  * aussi de bord à la zone figée, épaissi au survol, au focus
                  * clavier et pendant le glissement (`data-resizing`, posé hors React).
                  *
                  * `min(…, 100% - 8px)` : sur un écran plus étroit que la zone
                  * figée (téléphone, petite fenêtre), la barre reste au bord droit
                  * du tableau visible — là où on peut la saisir pour resserrer —
                  * au lieu de sortir du conteneur et d'élargir toute la page.
                  */}
                <div
                    ref={separatorRef}
                    role="separator"
                    tabIndex={0}
                    aria-orientation="vertical"
                    aria-valuemin={PINNED_MIN_WIDTH}
                    aria-valuemax={PINNED_FULL_WIDTH}
                    aria-valuenow={layout.width}
                    aria-label={t(NS, 'resize_label', 'Largeur des colonnes figées')}
                    title={t(NS, 'resize_hint', 'Glisser pour agrandir le calendrier · double-clic : largeur complète')}
                    {...separatorHandlers}
                    className="group/resize absolute inset-y-0 left-[min(calc(var(--yt-pinned-w)+1px),calc(100%-8px))] z-40 w-4 -translate-x-1/2 cursor-col-resize touch-none outline-none select-none"
                >
                    <span
                        aria-hidden="true"
                        className="bg-border group-hover/resize:bg-primary group-focus-visible/resize:bg-primary group-data-[resizing]/yt:bg-primary absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-[width,background-color] group-hover/resize:w-[3px] group-focus-visible/resize:w-[3px] group-data-[resizing]/yt:w-[3px]"
                    />
                    {/* La poignée, dans la hauteur de l'en-tête : elle signale qu'on
                        peut tirer, et c'est l'ancre de la visite — la barre entière
                        est plus haute que l'écran (TOURS.md §6.0). */}
                    <span
                        aria-hidden="true"
                        data-tour="yield-resize"
                        className="bg-background text-muted-foreground group-hover/resize:border-primary group-hover/resize:text-primary group-focus-visible/resize:ring-ring group-data-[resizing]/yt:border-primary group-data-[resizing]/yt:text-primary absolute top-2 left-1/2 flex h-7 w-3.5 -translate-x-1/2 items-center justify-center rounded border shadow-sm group-focus-visible/resize:ring-2"
                    >
                        <GripVertical className="h-3 w-3" />
                    </span>
                </div>
            </div>

            {tooltip.visible && (
                <div
                    role="tooltip"
                    style={{ left: tooltip.x, top: tooltip.y, maxWidth: TOOLTIP_MAX_W }}
                    className="bg-popover text-popover-foreground pointer-events-none fixed z-50 rounded border px-2 py-1 text-xs shadow"
                >
                    <div className="font-medium">{tooltipDate}</div>
                    <div>
                        ${tooltip.price} ({tooltip.tnd} TND)
                    </div>
                    {tooltip.booked && <div className="text-muted-foreground">{t(NS, 'day_booked', 'Réservé')}</div>}
                    {tooltip.reason && <div className="text-muted-foreground">{tooltip.reason}</div>}
                </div>
            )}
        </>
    );
}
