/**
 * scaffold-ui-primitives/generate.ts — Build the editable UI primitives
 * (EntityLookup, DateInput, EnumSelect, MultiSelect, TruncatedText,
 * ResponsiveDataTable) + i18n locale stubs.
 *
 * Pure function — no I/O. Caller in index.ts writes the returned files honoring
 * each file's `strategy` (overwrite vs deep-merge-json for locales).
 *
 * Theme-compliance contract: the form primitives MUST use only SmartStack tokens
 * (var(--bg-card), var(--color-accent-*), …) — zero hardcoded Tailwind colors
 * (bg-blue-500, text-gray-700, dark:bg-…). The companion test asserts this
 * with a regex sweep so a future hand-edit can't silently regress it.
 * TruncatedText / ResponsiveDataTable are pure layout/logic (no colors) — they
 * only add the responsive-column + truncation-tooltip behavior on top of the
 * local <DataTable>.
 */
import type { GeneratedFile, ScaffoldUiPrimitivesInput } from './types.js';

const HEADER = `/* AUTO-GENERATED — re-run scaffold-ui-primitives to refresh. Add @customised at the top to opt out. */`;

const LOCALES = ['fr', 'en', 'it', 'de'] as const;

// Namespaced i18n strings (locale → namespace → key). Each primitive reads its
// own namespace via useTranslation('common'); localeFile() writes the whole
// per-locale tree into common.json (deep-merged, sibling namespaces preserved).
const I18N_STRINGS: Record<typeof LOCALES[number], Record<string, Record<string, string>>> = {
  fr: {
    entityLookup: {
      placeholder: 'Rechercher…',
      loading: 'Chargement…',
      empty: 'Aucun résultat',
      error: 'Erreur de chargement',
      clear: 'Effacer la sélection',
      search: 'Rechercher',
    },
    dateInput: {
      placeholder: 'Choisir une date',
      clear: 'Effacer la date',
      previousMonth: 'Mois précédent',
      nextMonth: 'Mois suivant',
    },
    enumSelect: {
      placeholder: 'Sélectionner…',
      clear: 'Effacer',
      empty: 'Aucune option',
    },
    multiSelect: {
      placeholder: 'Sélectionner…',
      empty: 'Aucune option',
      remove: 'Retirer',
    },
  },
  en: {
    entityLookup: {
      placeholder: 'Search…',
      loading: 'Loading…',
      empty: 'No results',
      error: 'Failed to load',
      clear: 'Clear selection',
      search: 'Search',
    },
    dateInput: {
      placeholder: 'Select a date',
      clear: 'Clear date',
      previousMonth: 'Previous month',
      nextMonth: 'Next month',
    },
    enumSelect: {
      placeholder: 'Select…',
      clear: 'Clear',
      empty: 'No options',
    },
    multiSelect: {
      placeholder: 'Select…',
      empty: 'No options',
      remove: 'Remove',
    },
  },
  it: {
    entityLookup: {
      placeholder: 'Cerca…',
      loading: 'Caricamento…',
      empty: 'Nessun risultato',
      error: 'Errore di caricamento',
      clear: 'Cancella selezione',
      search: 'Cerca',
    },
    dateInput: {
      placeholder: 'Scegli una data',
      clear: 'Cancella data',
      previousMonth: 'Mese precedente',
      nextMonth: 'Mese successivo',
    },
    enumSelect: {
      placeholder: 'Seleziona…',
      clear: 'Cancella',
      empty: 'Nessuna opzione',
    },
    multiSelect: {
      placeholder: 'Seleziona…',
      empty: 'Nessuna opzione',
      remove: 'Rimuovi',
    },
  },
  de: {
    entityLookup: {
      placeholder: 'Suchen…',
      loading: 'Wird geladen…',
      empty: 'Keine Ergebnisse',
      error: 'Ladefehler',
      clear: 'Auswahl löschen',
      search: 'Suchen',
    },
    dateInput: {
      placeholder: 'Datum wählen',
      clear: 'Datum löschen',
      previousMonth: 'Vorheriger Monat',
      nextMonth: 'Nächster Monat',
    },
    enumSelect: {
      placeholder: 'Auswählen…',
      clear: 'Löschen',
      empty: 'Keine Optionen',
    },
    multiSelect: {
      placeholder: 'Auswählen…',
      empty: 'Keine Optionen',
      remove: 'Entfernen',
    },
  },
};

function entityLookupComponent(): string {
  return `${HEADER}
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { api } from '@atlashub/smartstack';
import { Search, ChevronDown, X, Loader2 } from 'lucide-react';

/**
 * Paginated reference shape returned by GET /api/{module}/{plural}/lookup.
 * Matches \`{Entity}RefDto\` emitted by scaffold-business.
 */
interface RefItem {
  id: string;
  displayName: string;
  [k: string]: unknown;
}

interface PaginatedResult<T> {
  items: T[];
  // Matches SmartStack.app PaginatedResult<T>.TotalCount on the wire (JSON: "totalCount").
  totalCount: number;
  page: number;
  pageSize: number;
}

export interface EntityLookupProps<TItem extends RefItem = RefItem> {
  /** Endpoint hitting the lookup contract, e.g. \`/api/hr/departments/lookup\`. */
  apiEndpoint: string;
  /** Currently selected entity id (the FK Guid to persist). \`null\` = empty. */
  value: string | null;
  /** Receives the selected id (or null on clear) and, when available, the
   *  full item so the caller can store a denormalised label without a refetch. */
  onChange: (id: string | null, item?: TItem) => void;
  /** Visible field label rendered above the input. */
  label: string;
  /** Override for the search input placeholder. Defaults to
   *  \`t('entityLookup.placeholder')\`. */
  placeholder?: string;
  /** Optional remap when the backend returns more than \`{ id, displayName }\`.
   *  Defaults to \`(it) => ({ id: it.id, label: it.displayName })\`. */
  mapOption?: (item: TItem) => { id: string; label: string; sublabel?: string };
  required?: boolean;
  disabled?: boolean;
  /** Error string rendered under the input — typically a form validation
   *  message from react-hook-form / zod. */
  error?: string;
  /** Multi-select — not implemented yet; reserved so the API doesn't break
   *  callers when the feature lands. Throws at runtime if set to true. */
  multi?: boolean;
}

const PAGE_SIZE = 20;
const DEBOUNCE_MS = 300;

export function EntityLookup<TItem extends RefItem = RefItem>({
  apiEndpoint,
  value,
  onChange,
  label,
  placeholder,
  mapOption,
  required = false,
  disabled = false,
  error,
  multi = false,
}: EntityLookupProps<TItem>) {
  if (multi) {
    throw new Error('EntityLookup: multi-select is not implemented yet (PR3 scope).');
  }
  const { t } = useTranslation('common');
  const [search, setSearch] = useState('');
  const [debouncedSearch, setDebouncedSearch] = useState('');
  const [open, setOpen] = useState(false);
  const [focusedIdx, setFocusedIdx] = useState(0);
  const inputRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLUListElement>(null);

  const [data, setData] = useState<PaginatedResult<TItem> | undefined>();
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);
  const [selectedItem, setSelectedItem] = useState<TItem | null>(null);

  // Debounce the search input so we don't fire a request per keystroke.
  useEffect(() => {
    const t = setTimeout(() => setDebouncedSearch(search), DEBOUNCE_MS);
    return () => clearTimeout(t);
  }, [search]);

  // Fetch lookup results when dropdown is open.
  const fetchResults = useCallback(async (signal: AbortSignal) => {
    setIsLoading(true);
    setIsError(false);
    try {
      const result = await api.get<PaginatedResult<TItem>>(apiEndpoint, {
        params: { search: debouncedSearch || undefined, page: 1, pageSize: PAGE_SIZE },
        signal,
      });
      setData(result);
    } catch (err) {
      if (!(err instanceof DOMException && err.name === 'AbortError')) setIsError(true);
    } finally {
      setIsLoading(false);
    }
  }, [apiEndpoint, debouncedSearch]);

  useEffect(() => {
    if (!open) return;
    const ac = new AbortController();
    void fetchResults(ac.signal);
    return () => ac.abort();
  }, [open, fetchResults]);

  // Resolve the currently selected item label so the input shows a human
  // name instead of the raw Guid.
  useEffect(() => {
    if (!value) { setSelectedItem(null); return; }
    let cancelled = false;
    api.get<PaginatedResult<TItem>>(apiEndpoint, { params: { search: value, pageSize: 1 } })
      .then((result) => { if (!cancelled) setSelectedItem(result.items.find((it) => it.id === value) ?? null); })
      .catch(() => { if (!cancelled) setSelectedItem(null); });
    return () => { cancelled = true; };
  }, [apiEndpoint, value]);

  const options = useMemo(() => {
    const items = data?.items ?? [];
    return items.map((it) => {
      const mapped = mapOption ? mapOption(it) : { id: it.id, label: it.displayName };
      return { ...mapped, raw: it };
    });
  }, [data, mapOption]);

  const selectedLabel = useMemo(() => {
    if (!value) return '';
    if (!selectedItem) return value;
    return mapOption ? mapOption(selectedItem).label : selectedItem.displayName;
  }, [value, selectedItem, mapOption]);

  function pick(idx: number): void {
    const opt = options[idx];
    if (!opt) return;
    onChange(opt.id, opt.raw);
    setOpen(false);
    setSearch('');
    inputRef.current?.blur();
  }

  function onKeyDown(e: KeyboardEvent<HTMLInputElement>): void {
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      if (!open) setOpen(true);
      setFocusedIdx((i) => Math.min(i + 1, options.length - 1));
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      setFocusedIdx((i) => Math.max(i - 1, 0));
    } else if (e.key === 'Enter') {
      e.preventDefault();
      pick(focusedIdx);
    } else if (e.key === 'Escape') {
      e.preventDefault();
      setOpen(false);
      inputRef.current?.blur();
    }
  }

  const inputId = useMemo(() => \`entity-lookup-\${Math.random().toString(36).slice(2, 9)}\`, []);
  const listId = \`\${inputId}-list\`;

  return (
    <div className="flex flex-col gap-1">
      <label htmlFor={inputId} className="text-sm font-medium text-[var(--text-primary)]">
        {label}
        {required && <span className="ml-0.5 text-[var(--color-danger-500)]" aria-label="required">*</span>}
      </label>
      <div className="relative">
        <div className="absolute inset-y-0 left-2.5 flex items-center pointer-events-none">
          {isLoading && open ? (
            <Loader2 className="w-4 h-4 animate-spin text-[var(--text-muted)]" />
          ) : (
            <Search className="w-4 h-4 text-[var(--text-muted)]" />
          )}
        </div>
        <input
          id={inputId}
          ref={inputRef}
          type="text"
          role="combobox"
          aria-expanded={open}
          aria-controls={listId}
          aria-autocomplete="list"
          aria-required={required}
          aria-invalid={Boolean(error)}
          value={open ? search : selectedLabel}
          onChange={(e) => {
            setSearch(e.target.value);
            setFocusedIdx(0);
            if (!open) setOpen(true);
          }}
          onFocus={() => setOpen(true)}
          onBlur={() => setTimeout(() => setOpen(false), 150)}
          onKeyDown={onKeyDown}
          placeholder={placeholder ?? t('entityLookup.placeholder', { defaultValue: 'Search…' })}
          disabled={disabled}
          className="w-full pl-9 pr-16 py-2 rounded-[var(--radius-input,0.375rem)] border border-[var(--border-color)] bg-[var(--bg-card)] text-[var(--text-primary)] placeholder-[var(--text-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-500)] focus:border-transparent disabled:opacity-60 disabled:cursor-not-allowed"
        />
        <div className="absolute inset-y-0 right-2 flex items-center gap-1">
          {value && !disabled && (
            <button
              type="button"
              onMouseDown={(e) => {
                e.preventDefault();
                onChange(null);
                setSearch('');
              }}
              aria-label={t('entityLookup.clear', { defaultValue: 'Clear selection' })}
              className="p-1 rounded hover:bg-[var(--bg-hover)] text-[var(--text-muted)]"
            >
              <X className="w-3.5 h-3.5" />
            </button>
          )}
          <ChevronDown className="w-4 h-4 text-[var(--text-muted)]" />
        </div>
        {open && (
          <ul
            id={listId}
            ref={listRef}
            role="listbox"
            className="absolute z-10 mt-1 w-full max-h-64 overflow-y-auto rounded-[var(--radius-input,0.375rem)] border border-[var(--border-color)] bg-[var(--bg-card)] shadow-lg"
          >
            {isError && (
              <li className="px-3 py-2 text-sm text-[var(--text-muted)]">
                {t('entityLookup.error', { defaultValue: 'Failed to load' })}
              </li>
            )}
            {!isError && isLoading && options.length === 0 && (
              <li className="px-3 py-2 text-sm text-[var(--text-muted)] flex items-center gap-2">
                <Loader2 className="w-3.5 h-3.5 animate-spin" />
                {t('entityLookup.loading', { defaultValue: 'Loading…' })}
              </li>
            )}
            {!isError && !isLoading && options.length === 0 && (
              <li className="px-3 py-2 text-sm text-[var(--text-muted)]">
                {t('entityLookup.empty', { defaultValue: 'No results' })}
              </li>
            )}
            {options.map((opt, idx) => (
              <li
                key={opt.id}
                role="option"
                aria-selected={value === opt.id}
                onMouseEnter={() => setFocusedIdx(idx)}
                onMouseDown={(e) => {
                  e.preventDefault();
                  pick(idx);
                }}
                className={\`px-3 py-2 text-sm cursor-pointer flex flex-col text-[var(--text-primary)] \${
                  idx === focusedIdx ? 'bg-[var(--bg-hover)]' : ''
                } \${value === opt.id ? 'font-medium' : ''}\`}
              >
                <span>{opt.label}</span>
                {opt.sublabel && (
                  <span className="text-xs text-[var(--text-muted)]">{opt.sublabel}</span>
                )}
              </li>
            ))}
          </ul>
        )}
      </div>
      {error && (
        <p role="alert" className="text-xs text-[var(--color-danger-500)]">
          {error}
        </p>
      )}
    </div>
  );
}
`;
}

/**
 * DateInput — theme-compliant date picker with a React-rendered calendar
 * popover (NOT the native `<input type="date">`, whose calendar is browser
 * chrome and ignores the theme). Value contract: ISO `YYYY-MM-DD` string or
 * null. Locale-aware display via Intl.DateTimeFormat(i18n.language).
 */
function dateInputComponent(): string {
  return `${HEADER}
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Calendar, ChevronLeft, ChevronRight, X } from 'lucide-react';

export interface DateInputProps {
  /** Selected date as an ISO \`YYYY-MM-DD\` string, or null when empty. */
  value: string | null;
  /** Receives the ISO date string (or null on clear). */
  onChange: (value: string | null) => void;
  /** Visible label above the field. Omit to render the control alone (e.g. in a filter bar). */
  label?: string;
  placeholder?: string;
  required?: boolean;
  disabled?: boolean;
  /** Validation message rendered under the field. */
  error?: string;
}

function pad2(n: number): string {
  return n < 10 ? '0' + n : String(n);
}
function toIso(d: Date): string {
  return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
function parseIso(s: string | null): Date | null {
  if (!s) return null;
  const m = /^(\\d{4})-(\\d{2})-(\\d{2})/.exec(s);
  if (!m) return null;
  const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
  return isNaN(d.getTime()) ? null : d;
}

export function DateInput({ value, onChange, label, placeholder, required = false, disabled = false, error }: DateInputProps) {
  const { t, i18n } = useTranslation('common');
  const selected = useMemo(() => parseIso(value), [value]);
  const [open, setOpen] = useState(false);
  const [view, setView] = useState<Date>(() => parseIso(value) ?? new Date());
  const rootRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (selected) setView(selected);
  }, [value]);

  useEffect(() => {
    if (!open) return;
    function onDoc(e: MouseEvent) {
      if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
    }
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [open]);

  const dayFmt = useMemo(() => new Intl.DateTimeFormat(i18n.language, { year: 'numeric', month: 'long', day: 'numeric' }), [i18n.language]);
  const monthFmt = useMemo(() => new Intl.DateTimeFormat(i18n.language, { month: 'long', year: 'numeric' }), [i18n.language]);
  const weekdayFmt = useMemo(() => new Intl.DateTimeFormat(i18n.language, { weekday: 'short' }), [i18n.language]);

  const weekdays = useMemo(() => {
    // Monday-first weekday labels (Mon Nov 1 2021 is a Monday).
    const base = new Date(2021, 10, 1);
    return Array.from({ length: 7 }, (_, i) => weekdayFmt.format(new Date(base.getFullYear(), base.getMonth(), base.getDate() + i)));
  }, [weekdayFmt]);

  const cells = useMemo(() => {
    const year = view.getFullYear();
    const month = view.getMonth();
    const startOffset = (new Date(year, month, 1).getDay() + 6) % 7;
    const daysInMonth = new Date(year, month + 1, 0).getDate();
    const out: (Date | null)[] = [];
    for (let i = 0; i < startOffset; i++) out.push(null);
    for (let d = 1; d <= daysInMonth; d++) out.push(new Date(year, month, d));
    while (out.length % 7 !== 0) out.push(null);
    return out;
  }, [view]);

  const todayIso = toIso(new Date());

  function onKeyDown(e: KeyboardEvent<HTMLButtonElement>) {
    if (disabled) return;
    if (e.key === 'Escape') { setOpen(false); return; }
    if ((e.key === 'Enter' || e.key === ' ') && !open) { e.preventDefault(); setOpen(true); return; }
    if (!open) return;
    const ref = selected ?? new Date();
    let next: Date | null = null;
    if (e.key === 'ArrowLeft') next = new Date(ref.getFullYear(), ref.getMonth(), ref.getDate() - 1);
    else if (e.key === 'ArrowRight') next = new Date(ref.getFullYear(), ref.getMonth(), ref.getDate() + 1);
    else if (e.key === 'ArrowUp') next = new Date(ref.getFullYear(), ref.getMonth(), ref.getDate() - 7);
    else if (e.key === 'ArrowDown') next = new Date(ref.getFullYear(), ref.getMonth(), ref.getDate() + 7);
    if (next) { e.preventDefault(); onChange(toIso(next)); setView(next); }
  }

  const inputId = useMemo(() => 'date-input-' + Math.random().toString(36).slice(2, 9), []);

  return (
    <div className="flex flex-col gap-1" ref={rootRef}>
      {label && (
        <label htmlFor={inputId} className="text-sm font-medium text-[var(--text-primary)]">
          {label}
          {required && <span className="ml-0.5 text-[var(--color-danger-500)]" aria-label="required">*</span>}
        </label>
      )}
      <div className="relative">
        <span className="absolute inset-y-0 left-2.5 flex items-center pointer-events-none">
          <Calendar className="w-4 h-4 text-[var(--text-muted)]" />
        </span>
        <button
          id={inputId}
          type="button"
          role="combobox"
          aria-haspopup="dialog"
          aria-expanded={open}
          aria-required={required}
          aria-invalid={Boolean(error)}
          disabled={disabled}
          onClick={() => setOpen((o) => !o)}
          onKeyDown={onKeyDown}
          className="w-full flex items-center pl-9 pr-9 py-2 text-left rounded-[var(--radius-input,0.375rem)] border border-[var(--border-color)] bg-[var(--bg-card)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-500)] focus:border-transparent disabled:opacity-60 disabled:cursor-not-allowed"
        >
          {selected ? dayFmt.format(selected) : <span className="text-[var(--text-muted)]">{placeholder ?? t('dateInput.placeholder', { defaultValue: 'Select a date' })}</span>}
        </button>
        {value && !disabled && (
          <button
            type="button"
            onClick={() => onChange(null)}
            aria-label={t('dateInput.clear', { defaultValue: 'Clear date' })}
            className="absolute inset-y-0 right-2 flex items-center text-[var(--text-muted)] hover:text-[var(--text-primary)]"
          >
            <X className="w-3.5 h-3.5" />
          </button>
        )}
        {open && (
          <div role="dialog" className="absolute z-20 mt-1 w-72 rounded-[var(--radius-input,0.375rem)] border border-[var(--border-color)] bg-[var(--bg-card)] shadow-lg p-3">
            <div className="flex items-center justify-between mb-2">
              <button
                type="button"
                aria-label={t('dateInput.previousMonth', { defaultValue: 'Previous month' })}
                onClick={() => setView(new Date(view.getFullYear(), view.getMonth() - 1, 1))}
                className="p-1 rounded hover:bg-[var(--bg-hover)] text-[var(--text-muted)]"
              >
                <ChevronLeft className="w-4 h-4" />
              </button>
              <span className="text-sm font-medium text-[var(--text-primary)] capitalize">{monthFmt.format(view)}</span>
              <button
                type="button"
                aria-label={t('dateInput.nextMonth', { defaultValue: 'Next month' })}
                onClick={() => setView(new Date(view.getFullYear(), view.getMonth() + 1, 1))}
                className="p-1 rounded hover:bg-[var(--bg-hover)] text-[var(--text-muted)]"
              >
                <ChevronRight className="w-4 h-4" />
              </button>
            </div>
            <div className="grid grid-cols-7 gap-0.5 mb-1">
              {weekdays.map((w, i) => (
                <span key={i} className="text-center text-xs font-medium text-[var(--text-muted)] py-1">{w}</span>
              ))}
            </div>
            <div className="grid grid-cols-7 gap-0.5">
              {cells.map((d, i) => {
                if (!d) return <span key={i} />;
                const iso = toIso(d);
                const isSelected = value === iso;
                const isToday = iso === todayIso;
                const cls = 'h-8 w-8 mx-auto flex items-center justify-center text-sm rounded-[var(--radius-input,0.375rem)] ' + (isSelected ? 'bg-[var(--color-accent-500)] text-[var(--text-inverse)] font-medium' : 'text-[var(--text-primary)] hover:bg-[var(--bg-hover)]') + (isToday && !isSelected ? ' ring-1 ring-[var(--color-accent-500)]' : '');
                return (
                  <button key={i} type="button" aria-selected={isSelected} aria-label={dayFmt.format(d)} onClick={() => { onChange(iso); setOpen(false); }} className={cls}>
                    {d.getDate()}
                  </button>
                );
              })}
            </div>
          </div>
        )}
      </div>
      {error && <p role="alert" className="text-xs text-[var(--color-danger-500)]">{error}</p>}
    </div>
  );
}
`;
}

/**
 * EnumSelect — theme-compliant single-select dropdown for enum / fixed-list
 * fields (replaces the native `<select>` and the `<input type="text">`
 * fallback). Value contract: the selected option value string, or null.
 */
function enumSelectComponent(): string {
  return `${HEADER}
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Check, ChevronDown, X } from 'lucide-react';

export interface SelectOption {
  value: string;
  label: string;
}

export interface EnumSelectProps {
  options: SelectOption[];
  /** Selected option value, or null when empty. */
  value: string | null;
  onChange: (value: string | null) => void;
  /** Visible label above the field. Omit to render the control alone. */
  label?: string;
  placeholder?: string;
  required?: boolean;
  disabled?: boolean;
  /** Show the inline clear (X) button. Default true. */
  clearable?: boolean;
  error?: string;
}

export function EnumSelect({ options, value, onChange, label, placeholder, required = false, disabled = false, clearable = true, error }: EnumSelectProps) {
  const { t } = useTranslation('common');
  const [open, setOpen] = useState(false);
  const [focusedIdx, setFocusedIdx] = useState(0);
  const rootRef = useRef<HTMLDivElement>(null);

  const selectedOption = useMemo(() => options.find((o) => o.value === value) ?? null, [options, value]);

  useEffect(() => {
    if (!open) return;
    function onDoc(e: MouseEvent) {
      if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
    }
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [open]);

  function pick(idx: number) {
    const opt = options[idx];
    if (!opt) return;
    onChange(opt.value);
    setOpen(false);
  }

  function onKeyDown(e: KeyboardEvent<HTMLButtonElement>) {
    if (disabled) return;
    if (e.key === 'ArrowDown') { e.preventDefault(); if (!open) setOpen(true); setFocusedIdx((i) => Math.min(i + 1, options.length - 1)); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); setFocusedIdx((i) => Math.max(i - 1, 0)); }
    else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (open) pick(focusedIdx); else setOpen(true); }
    else if (e.key === 'Escape') { setOpen(false); }
  }

  const inputId = useMemo(() => 'enum-select-' + Math.random().toString(36).slice(2, 9), []);
  const listId = inputId + '-list';

  return (
    <div className="flex flex-col gap-1" ref={rootRef}>
      {label && (
        <label htmlFor={inputId} className="text-sm font-medium text-[var(--text-primary)]">
          {label}
          {required && <span className="ml-0.5 text-[var(--color-danger-500)]" aria-label="required">*</span>}
        </label>
      )}
      <div className="relative">
        <button
          id={inputId}
          type="button"
          role="combobox"
          aria-haspopup="listbox"
          aria-expanded={open}
          aria-controls={listId}
          aria-required={required}
          aria-invalid={Boolean(error)}
          disabled={disabled}
          onClick={() => setOpen((o) => !o)}
          onKeyDown={onKeyDown}
          className="w-full flex items-center justify-between gap-2 pl-3 pr-9 py-2 text-left rounded-[var(--radius-input,0.375rem)] border border-[var(--border-color)] bg-[var(--bg-card)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-500)] focus:border-transparent disabled:opacity-60 disabled:cursor-not-allowed"
        >
          {selectedOption
            ? <span className="truncate">{selectedOption.label}</span>
            : <span className="truncate text-[var(--text-muted)]">{placeholder ?? t('enumSelect.placeholder', { defaultValue: 'Select…' })}</span>}
        </button>
        <span className="absolute inset-y-0 right-2 flex items-center gap-1 pointer-events-none">
          {clearable && value && !disabled && (
            <button
              type="button"
              onMouseDown={(e) => { e.preventDefault(); onChange(null); }}
              aria-label={t('enumSelect.clear', { defaultValue: 'Clear' })}
              className="p-1 rounded hover:bg-[var(--bg-hover)] text-[var(--text-muted)] pointer-events-auto"
            >
              <X className="w-3.5 h-3.5" />
            </button>
          )}
          <ChevronDown className="w-4 h-4 text-[var(--text-muted)]" />
        </span>
        {open && (
          <ul id={listId} role="listbox" className="absolute z-20 mt-1 w-full max-h-64 overflow-y-auto rounded-[var(--radius-input,0.375rem)] border border-[var(--border-color)] bg-[var(--bg-card)] shadow-lg">
            {options.length === 0 && (
              <li className="px-3 py-2 text-sm text-[var(--text-muted)]">{t('enumSelect.empty', { defaultValue: 'No options' })}</li>
            )}
            {options.map((opt, idx) => {
              const isSel = opt.value === value;
              const cls = 'px-3 py-2 text-sm cursor-pointer flex items-center justify-between text-[var(--text-primary)] ' + (idx === focusedIdx ? 'bg-[var(--bg-hover)]' : '') + (isSel ? ' font-medium' : '');
              return (
                <li key={opt.value} role="option" aria-selected={isSel} onMouseEnter={() => setFocusedIdx(idx)} onMouseDown={(e) => { e.preventDefault(); pick(idx); }} className={cls}>
                  <span className="truncate">{opt.label}</span>
                  {isSel && <Check className="w-4 h-4 text-[var(--color-accent-500)]" />}
                </li>
              );
            })}
          </ul>
        )}
      </div>
      {error && <p role="alert" className="text-xs text-[var(--color-danger-500)]">{error}</p>}
    </div>
  );
}
`;
}

/**
 * MultiSelect — theme-compliant multi-select with removable chips. Value
 * contract: an array of selected option values (string[]).
 */
function multiSelectComponent(): string {
  return `${HEADER}
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Check, ChevronDown, X } from 'lucide-react';

export interface MultiSelectOption {
  value: string;
  label: string;
}

export interface MultiSelectProps {
  options: MultiSelectOption[];
  /** Selected option values. */
  value: string[];
  onChange: (value: string[]) => void;
  /** Visible label above the field. Omit to render the control alone. */
  label?: string;
  placeholder?: string;
  required?: boolean;
  disabled?: boolean;
  error?: string;
}

export function MultiSelect({ options, value, onChange, label, placeholder, required = false, disabled = false, error }: MultiSelectProps) {
  const { t } = useTranslation('common');
  const [open, setOpen] = useState(false);
  const [focusedIdx, setFocusedIdx] = useState(0);
  const rootRef = useRef<HTMLDivElement>(null);

  const selectedSet = useMemo(() => new Set(value), [value]);
  const selectedOptions = useMemo(() => options.filter((o) => selectedSet.has(o.value)), [options, selectedSet]);

  useEffect(() => {
    if (!open) return;
    function onDoc(e: MouseEvent) {
      if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
    }
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [open]);

  function toggle(v: string) {
    if (selectedSet.has(v)) onChange(value.filter((x) => x !== v));
    else onChange([...value, v]);
  }

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    if (disabled) return;
    if (e.key === 'ArrowDown') { e.preventDefault(); if (!open) setOpen(true); setFocusedIdx((i) => Math.min(i + 1, options.length - 1)); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); setFocusedIdx((i) => Math.max(i - 1, 0)); }
    else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (open) { const opt = options[focusedIdx]; if (opt) toggle(opt.value); } else setOpen(true); }
    else if (e.key === 'Escape') { setOpen(false); }
  }

  const inputId = useMemo(() => 'multi-select-' + Math.random().toString(36).slice(2, 9), []);
  const listId = inputId + '-list';

  return (
    <div className="flex flex-col gap-1" ref={rootRef}>
      {label && (
        <label htmlFor={inputId} className="text-sm font-medium text-[var(--text-primary)]">
          {label}
          {required && <span className="ml-0.5 text-[var(--color-danger-500)]" aria-label="required">*</span>}
        </label>
      )}
      <div className="relative">
        <div
          id={inputId}
          role="combobox"
          tabIndex={disabled ? -1 : 0}
          aria-haspopup="listbox"
          aria-expanded={open}
          aria-controls={listId}
          aria-required={required}
          aria-invalid={Boolean(error)}
          onClick={() => { if (!disabled) setOpen((o) => !o); }}
          onKeyDown={onKeyDown}
          className="w-full min-h-[2.5rem] flex flex-wrap items-center gap-1 pl-2 pr-9 py-1.5 cursor-pointer rounded-[var(--radius-input,0.375rem)] border border-[var(--border-color)] bg-[var(--bg-card)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-500)] focus:border-transparent"
        >
          {selectedOptions.length === 0 && (
            <span className="text-[var(--text-muted)]">{placeholder ?? t('multiSelect.placeholder', { defaultValue: 'Select…' })}</span>
          )}
          {selectedOptions.map((opt) => (
            <span key={opt.value} className="inline-flex items-center gap-1 pl-2 pr-1 py-0.5 text-xs rounded-[var(--radius-input,0.375rem)] bg-[var(--bg-hover)] text-[var(--text-primary)]">
              {opt.label}
              <button
                type="button"
                tabIndex={-1}
                disabled={disabled}
                aria-label={t('multiSelect.remove', { defaultValue: 'Remove' })}
                onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); toggle(opt.value); }}
                className="text-[var(--text-muted)] hover:text-[var(--text-primary)]"
              >
                <X className="w-3 h-3" />
              </button>
            </span>
          ))}
        </div>
        <span className="absolute inset-y-0 right-2 flex items-center pointer-events-none">
          <ChevronDown className="w-4 h-4 text-[var(--text-muted)]" />
        </span>
        {open && (
          <ul id={listId} role="listbox" aria-multiselectable="true" className="absolute z-20 mt-1 w-full max-h-64 overflow-y-auto rounded-[var(--radius-input,0.375rem)] border border-[var(--border-color)] bg-[var(--bg-card)] shadow-lg">
            {options.length === 0 && (
              <li className="px-3 py-2 text-sm text-[var(--text-muted)]">{t('multiSelect.empty', { defaultValue: 'No options' })}</li>
            )}
            {options.map((opt, idx) => {
              const isSel = selectedSet.has(opt.value);
              const cls = 'px-3 py-2 text-sm cursor-pointer flex items-center justify-between text-[var(--text-primary)] ' + (idx === focusedIdx ? 'bg-[var(--bg-hover)]' : '');
              return (
                <li key={opt.value} role="option" aria-selected={isSel} onMouseEnter={() => setFocusedIdx(idx)} onMouseDown={(e) => { e.preventDefault(); toggle(opt.value); }} className={cls}>
                  <span className="truncate">{opt.label}</span>
                  {isSel && <Check className="w-4 h-4 text-[var(--color-accent-500)]" />}
                </li>
              );
            })}
          </ul>
        )}
      </div>
      {error && <p role="alert" className="text-xs text-[var(--color-danger-500)]">{error}</p>}
    </div>
  );
}
`;
}

/**
 * TruncatedText — clips its content with an ellipsis when it overflows and shows
 * the full value in a tooltip on hover/focus (the "popup si le libellé est
 * tronqué" requirement). Pure layout: no colors, no i18n. Reuses the package
 * `Tooltip`, gated by its `disabled` prop so the popup only appears when the
 * text is actually truncated (`scrollWidth > clientWidth`).
 */
function truncatedTextComponent(): string {
  return `${HEADER}
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Tooltip } from '@atlashub/smartstack';

export interface TruncatedTextProps {
  /** Cell content. When it overflows \`maxWidth\` it is clipped with an ellipsis
   *  and the full text is shown in a tooltip on hover/focus. */
  children: ReactNode;
  /** Full text for the tooltip. Defaults to \`children\` when it is a string. */
  title?: string;
  /** Max content width before clipping. A fixed cap is used so the ellipsis
   *  triggers even when the host <td> has no fixed table layout; once the
   *  DataTable engine ships \`table-layout: fixed\` you can pass \`maxWidth="100%"\`. */
  maxWidth?: string;
  className?: string;
}

export function TruncatedText({ children, title, maxWidth = '28rem', className = '' }: TruncatedTextProps) {
  const ref = useRef<HTMLSpanElement>(null);
  const [truncated, setTruncated] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const measure = () => setTruncated(el.scrollWidth > el.clientWidth + 1);
    measure();
    if (typeof ResizeObserver === 'undefined') return;
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, [children, maxWidth]);

  const full = title ?? (typeof children === 'string' ? children : undefined);

  return (
    <Tooltip content={full ?? ''} position="top" disabled={!truncated || !full}>
      <span ref={ref} className={\`block truncate min-w-0 \${className}\`} style={{ maxWidth }}>
        {children}
      </span>
    </Tooltip>
  );
}

export default TruncatedText;
`;
}

/**
 * ResponsiveDataTable — wrapper around the local <DataTable> that:
 *  1. shows/hides columns by viewport breakpoint. The default columns (code,
 *     label, actions) carry NO minBreakpoint so they are ALWAYS visible; extra
 *     columns declare a minBreakpoint and appear as the screen widens.
 *  2. clips `truncate` columns with an ellipsis + tooltip (via TruncatedText).
 * The column drop happens in JS (window.innerWidth) BEFORE reaching <DataTable>,
 * so it works on any DataTable version — it does not rely on the package's CSS
 * column hiding. Pure layout/logic: no colors, no i18n.
 */
function responsiveDataTableComponent(): string {
  return `${HEADER}
import { useEffect, useState } from 'react';
import { DataTable, type DataTableColumn, type DataTableProps } from '@/components/ui/DataTable';
import { TruncatedText } from '@/components/ui/TruncatedText';

export type ColumnBreakpoint = 'sm' | 'md' | 'lg' | 'xl';

/** A DataTable column enriched with responsive + truncation metadata. */
export type ResponsiveColumn<T> = DataTableColumn<T> & {
  /** Minimum viewport breakpoint at which the column appears. Omit = always visible. */
  minBreakpoint?: ColumnBreakpoint;
  /** Clip overflow with an ellipsis + show the full value in a tooltip on hover. */
  truncate?: boolean;
};

export interface ResponsiveDataTableProps<T> extends Omit<DataTableProps<T>, 'columns'> {
  columns: ResponsiveColumn<T>[];
}

// Tailwind's default breakpoints (px). Keep in sync with tailwind.config.
const BREAKPOINTS: Record<ColumnBreakpoint, number> = { sm: 640, md: 768, lg: 1024, xl: 1280 };

// The actions column is never dropped, whatever the width.
const ALWAYS_KEY = '__actions';

function getNestedValue(obj: unknown, path: string): unknown {
  return path.split('.').reduce((acc: unknown, part: string) => {
    if (acc && typeof acc === 'object' && part in acc) {
      return (acc as Record<string, unknown>)[part];
    }
    return undefined;
  }, obj);
}

/** Tracks the viewport width via window.innerWidth (jsdom-friendly — no media-query listener API needed). */
function useViewportWidth(): number {
  const [width, setWidth] = useState<number>(() => (typeof window === 'undefined' ? BREAKPOINTS.xl : window.innerWidth));
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const onResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', onResize);
    onResize();
    return () => window.removeEventListener('resize', onResize);
  }, []);
  return width;
}

export function ResponsiveDataTable<T>({ columns, ...rest }: ResponsiveDataTableProps<T>) {
  const width = useViewportWidth();

  const visible = columns
    .filter((c) => c.key === ALWAYS_KEY || !c.minBreakpoint || width >= BREAKPOINTS[c.minBreakpoint])
    .map((c): DataTableColumn<T> => {
      if (!c.truncate || c.render) return c;
      const key = c.key;
      return { ...c, render: (item: T) => <TruncatedText>{String(getNestedValue(item, key) ?? '')}</TruncatedText> };
    });

  return <DataTable<T> columns={visible} {...rest} />;
}

export default ResponsiveDataTable;
`;
}

function localeFile(locale: typeof LOCALES[number]): GeneratedFile {
  // I18N_STRINGS[locale] is already namespaced (entityLookup / dateInput /
  // enumSelect / multiSelect); deep-merge-json preserves sibling namespaces.
  return {
    path: `src/i18n/locales/${locale}/common.json`,
    content: JSON.stringify(I18N_STRINGS[locale], null, 2) + '\n',
    strategy: 'deep-merge-json',
  };
}

export function generate(_spec: ScaffoldUiPrimitivesInput): GeneratedFile[] {
  return [
    { path: 'src/components/ui/EntityLookup.tsx', content: entityLookupComponent(), strategy: 'overwrite' },
    { path: 'src/components/ui/DateInput.tsx', content: dateInputComponent(), strategy: 'overwrite' },
    { path: 'src/components/ui/EnumSelect.tsx', content: enumSelectComponent(), strategy: 'overwrite' },
    { path: 'src/components/ui/MultiSelect.tsx', content: multiSelectComponent(), strategy: 'overwrite' },
    { path: 'src/components/ui/TruncatedText.tsx', content: truncatedTextComponent(), strategy: 'overwrite' },
    { path: 'src/components/ui/ResponsiveDataTable.tsx', content: responsiveDataTableComponent(), strategy: 'overwrite' },
    ...LOCALES.map(localeFile),
  ];
}
