// ─── Alta / edición genérica de registros (RecordFormScreen) ────────
// Pantalla in-place editable (no modal — regla 8) reutilizable por las 4
// entidades del Directorio (entidad/medico/particular/proveedor — decisión D6:
// conviven RecordFormScreen y las *DetailScreen estilo-documento). Persiste en
// el store (MicosStore.create/update/remove) y, al guardar un alta, navega al
// detalle del registro recién creado. Expone a window: RecordFormScreen,
// RECORD_FORMS. Consume window.MicosStore, window.AltaSelect (ui/primitives.jsx, P6),
// window.ConfirmDeleteModal, window.MicosTodayISO/MicosNow, window.
// MicosNextSeqId (dominio/secuencias.js), window.ESPECIALIDADES (dominio/
// pipeline.js). P4 (poda D6): MUEREN RECORD_FORMS.cajaChica/inventario/compras/
// logistica (reemplazadas por las *DetailScreen in-place, screens-others.jsx/
// screens-inventario.jsx — 0 callers verificados) y los field-types
// RFProveedorField/RFItemsField (solo esas 4 configs muertas los usaban); las
// 2 ramas idénticas de RFField (option vacía con/sin required) se colapsan en
// una; se borró el bloque `if (false && ...)` de inicialización de select
// requerido (deshabilitado a mano, dead code). Quedan las 4 configs vivas.
//
// P18: `RecordFormModal` (al final del archivo) envuelve RecordFormScreen en
// un overlay — reusa el motor ENTERO (config declarativa + validación +
// persistencia real vía MicosStore.create) para altas disparadas DESDE otra
// pantalla que no puede perder su propio contexto (hoy: expediente a medio
// cargar, botón "Crear paciente/médico/entidad" bajo cada parte —
// screens/expedientes/alta.jsx). RecordFormScreen suma el prop opcional
// `backLabel` (si no viene, usa `cfg.backLabel` como siempre — default
// preservado) para que el modal pueda decir "Cancelar" en vez de "Volver a
// <Módulo>", que no tiene sentido cuando no se está navegando a esa lista.
// P5: `nextSeqPublicId` (era local a este archivo, firma (rows,prefix,digits,
// idKey) distinta de la del resto — footgun señalado por el mapa) se borró y
// su único call-site pasa a `window.MicosNextSeqId(rows, idKey, prefix, pad)`
// (fusión en dominio/secuencias.js). Fallback duplicado de ESPECIALIDADES
// borrado — window.ESPECIALIDADES ya se expone de una vez.
//
// FIX (hallazgo id-race, reporte P10 §directorio — mismo patrón ya aplicado a
// Expedientes/94e7f57 e Inventario/b9a6993): el alta ya NO fabrica `id` de
// cliente (antes `'ent-'+Date.now()`, etc. — esquema DISTINTO del `_tmp-…`
// que asigna MicosStore.create(), que además de inconsistente rompía el
// borrado rápido create+remove, ver core/store.js B8). Ahora se navega con
// `created.id` (la MISMA referencia que el store reconcilia in-place al
// uuid real). El componente recibe `recordId` (no un `record` ya resuelto):
// si el usuario entra a Editar DESPUÉS de que esa reconciliación ya ocurrió
// (selected<Col>Id de app.jsx quedó con el id viejo, el objeto ya no
// responde a `find(x => x.id === recordId)`), pasan DOS cosas en conjunto
// (ninguna alcanza sola, verificado en vivo — un fetch por el id temporal
// 404 SIEMPRE, nunca existió en el server): (1) `onIdReconciled` (3er
// argumento de MicosStore.create(), core/store.js) le avisa a app.jsx el
// uuid real apenas el POST resuelve, así corrige `selected<Col>Id` aunque
// el usuario ya haya navegado — se autocorrige solo, sin F5; (2) mientras
// tanto, el guard de fetch-and-merge de abajo cubre el caso GENÉRICO de
// edición con un id real que no está en la ventana hidratada (paginación/
// BIG_LIMIT, no exclusivo del id-race). Ninguno de los dos casos cae a un
// alta en blanco (que antes pisaba el registro real con vacíos al guardar).
//
// Config por módulo: ver RECORD_FORMS más abajo. Cada campo:
//   { key, label, type, options?, required?, placeholder?, help?, span?, default? }
//   type: 'text' | 'number' | 'money' | 'date' | 'time' | 'select' | 'textarea'
//   span: 1 (media fila) | 2 (fila completa) — sobre una grilla de 2 columnas.

const { useState: _useStateRF, useMemo: _useMemoRF, useEffect: _useEffectRF, useRef: _useRefRF } = React;

function rfTodayISO() { return (window.MicosTodayISO()); }
function rfNowHHMM() {
  const d = new Date();
  return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
}

// Campo individual (label + control). Definido a scope de módulo (no remonta).
function RFField({ field, value, onChange, error }) {
  const f = field;
  const common = {
    className: 'input',
    value: value ?? '',
    onChange: (e) => onChange(f.key, e.target.value),
    placeholder: f.placeholder || '',
  };
  let control;
  if (f.type === 'select') {
    const opts = typeof f.options === 'function' ? f.options() : (f.options || []);
    control = (
      <AltaSelect value={value ?? ''} onChange={(e) => onChange(f.key, e.target.value)}>
        <option value="">Seleccionar…</option>
        {opts.map((o) => {
          const val = typeof o === 'string' ? o : o.value;
          const lab = typeof o === 'string' ? o : o.label;
          return <option key={val} value={val}>{lab}</option>;
        })}
      </AltaSelect>
    );
  } else if (f.type === 'textarea') {
    control = (
      <textarea {...common} rows={f.rows || 3}
        style={{ minHeight: 0, height: 'auto', paddingTop: 8, paddingBottom: 8, resize: 'vertical', lineHeight: 1.5 }} />
    );
  } else if (f.type === 'money' || f.type === 'number') {
    control = (
      <div className="relative">
        {f.type === 'money' && (
          <span className="absolute left-3 top-1/2 -translate-y-1/2 text-[13px] font-bold pointer-events-none"
            style={{ color: 'var(--muted-foreground)' }}>$</span>
        )}
        <input {...common} type="number" inputMode="decimal" min={f.min}
          style={f.type === 'money' ? { paddingLeft: 24 } : undefined}
          className="input tabular-nums" />
      </div>
    );
  } else {
    control = <input {...common} type={f.type === 'date' ? 'date' : f.type === 'time' ? 'time' : 'text'}
      className={`input${f.mono ? ' font-mono tabular-nums' : ''}`} />;
  }
  return (
    <div style={{ gridColumn: f.span === 2 ? '1 / -1' : 'auto' }}>
      <label className="block text-[11px] font-bold uppercase tracking-[0.12em] mb-1.5" style={{ color: 'var(--muted-foreground)' }}>
        {f.label}{f.required && <span style={{ color: 'var(--accent)' }}>*</span>}
      </label>
      {control}
      {f.help && !error && <p className="text-[11px] mt-1" style={{ color: 'var(--muted-foreground)' }}>{f.help}</p>}
      {error && <p className="text-[11px] font-semibold mt-1" style={{ color: 'var(--danger)' }}>{error}</p>}
    </div>
  );
}

function RecordFormScreen({ config, recordId, onCancel, onSaved, onDeleted, onIdReconciled, backLabel }) {
  const cfg = config;
  const isEdit = !!recordId;
  // P18: override opcional del texto del link "volver" — default preservado
  // (cfg.backLabel, como siempre) para todo caller existente; solo lo pisa
  // RecordFormModal, donde "Volver a Entidades" no tiene sentido dentro de
  // un overlay que no navega a ningún lado.
  const effectiveBackLabel = backLabel || cfg.backLabel;

  // ── Guard fetch-and-merge (id-race — ver header) ──────────────────────
  // `record` YA NO llega resuelto por el padre: se busca acá, en cada
  // render, contra la colección global VIVA (mismo criterio que
  // InventarioDetailScreen, screens/inventario/detalle.jsx) — así, cuando
  // `setMergeTick` fuerza un re-render tras el fetch, `foundLocal` se
  // recalcula con el dato recién mergeado.
  const schema = (window.MicosStore && window.MicosStore.SCHEMA[cfg.collection]) || null;
  const globalKey = schema && schema.global;
  const foundLocal = (isEdit && globalKey) ? (window[globalKey] || []).find((x) => x.id === recordId) : null;
  const record = isEdit ? foundLocal : null;
  const [, setMergeTick] = _useStateRF(0);
  const [notFound, setNotFound] = _useStateRF(false);
  _useEffectRF(() => {
    if (!isEdit || foundLocal || !schema) return undefined;
    let cancelled = false;
    fetch(`${schema.endpoint}/${encodeURIComponent(recordId)}`, { credentials: 'same-origin' })
      .then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
      .then((server) => {
        if (cancelled || !server || !server.id) throw new Error('sin id');
        const col = window[globalKey] || (window[globalKey] = []);
        if (!col.some((x) => x.id === server.id)) col.push(server);
        setMergeTick((t) => t + 1);
      })
      .catch((err) => {
        if (cancelled) return;
        console.warn('[RecordFormScreen] fetch-and-merge', cfg.collection, recordId, err);
        setNotFound(true);
      });
    return () => { cancelled = true; };
  }, [isEdit, recordId, foundLocal, schema]);

  const allFields = _useMemoRF(() => cfg.sections.reduce((a, s) => a.concat(s.fields), []), [cfg]);

  const buildForm = (rec) => {
    const init = {};
    allFields.forEach((f) => {
      // `fromRecord` (opcional): para campos cuyo valor real no vive plano en `rec[f.key]`
      // sino derivado/anidado (ej. `entidadId` de la cobertura actual del particular, que
      // llega como `rec.coberturaActual.entidadId` — ver hallazgo #19 de la auditoría).
      let v = rec ? (f.fromRecord ? f.fromRecord(rec) : rec[f.key]) : (typeof f.default === 'function' ? f.default() : f.default);
      if (f.type === 'items') v = Array.isArray(v) ? v : [];
      else if (v == null) v = '';
      init[f.key] = v;
    });
    return init;
  };
  const [form, setForm] = _useStateRF(() => buildForm(record));
  // Re-siembra el form si `record` recién resuelve DESPUÉS del mount (el
  // useState de arriba es un initializer perezoso, corre una sola vez): sin
  // este efecto, el guard de arriba traía el registro real pero el form
  // seguía mostrando los valores vacíos con los que arrancó mientras
  // esperaba. `seededRef` evita re-sembrar de nuevo si ya arrancó con el
  // registro real (caso común, sin race).
  const seededRef = _useRefRF(!!record);
  _useEffectRF(() => {
    if (seededRef.current || !record) return;
    seededRef.current = true;
    setForm(buildForm(record));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [record]);

  const [touched, setTouched] = _useStateRF(false);
  const [delOpen, setDelOpen] = _useStateRF(false);

  const rows = _useMemoRF(() => (window.MicosStore ? window.MicosStore.getAll(cfg.collection) : []), [cfg.collection]);
  const idKey = cfg.idKey || 'publicId';
  const autoId = cfg.autoId !== false;
  // ID mostrado en el header: autogenerado secuencial, o el valor de un campo
  // que carga el usuario (ej: SKU en Inventario). Deps `[record]` (antes
  // `[]`): en alta `record` es siempre null (se congela en el primer render,
  // mismo comportamiento que antes); en edición recalcula una única vez,
  // justo cuando el guard de arriba resuelve el registro real.
  const generatedId = _useMemoRF(
    () => (isEdit ? ((record && record[idKey]) || '—') : window.MicosNextSeqId(rows, idKey, cfg.idPrefix, cfg.idDigits)),
    [record]
  );

  // Resolviendo (recordId de edición todavía sin encontrar local ni por
  // fetch) o confirmado inexistente en el server — NUNCA se reemplaza esto
  // por un alta en blanco (ver guard arriba: ahí está el fix real del bug).
  if (isEdit && !record) {
    if (notFound) {
      return (
        <div className="space-y-6 pt-2">
          <button onClick={onCancel}
            className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold cursor-pointer"
            style={{ color: 'var(--muted-foreground)' }}>
            <IconArrowLeft size={14} /> {effectiveBackLabel}
          </button>
          <p className="text-[14px] font-semibold" style={{ color: 'var(--muted-foreground)' }}>
            No se encontró el registro.
          </p>
        </div>
      );
    }
    return null;
  }

  const headerId = autoId
    ? generatedId
    : (isEdit ? (record[cfg.idField] || '—') : (form[cfg.idField] || cfg.idPlaceholder || '—'));

  const set = (k, v) => setForm((s) => ({ ...s, [k]: v }));

  const errors = {};
  allFields.forEach((f) => {
    if (f.required && !String(form[f.key] ?? '').trim()) errors[f.key] = 'Requerido';
  });
  const canSave = Object.keys(errors).length === 0;

  const titleText = isEdit
    ? (cfg.titleFrom ? cfg.titleFrom(form) : (cfg.titleEdit || 'Editar registro'))
    : (cfg.titleFrom ? (cfg.titleFrom(form) || cfg.titleNew) : cfg.titleNew);

  const handleCancel = () => {
    const dirty = allFields.some((f) => {
      const orig = record ? (record[f.key] ?? '') : (typeof f.default === 'function' ? f.default() : (f.default ?? ''));
      return String(form[f.key] ?? '') !== String(orig ?? '');
    });
    if (!isEdit && dirty && !window.confirm('¿Descartar? Se perderán los datos cargados.')) return;
    onCancel();
  };

  const handleSave = () => {
    setTouched(true);
    if (!canSave) return;
    let payload = {};
    allFields.forEach((f) => {
      let v = form[f.key];
      if (f.type === 'money' || f.type === 'number') v = v === '' ? null : Number(v);
      if (typeof v === 'string') v = v.trim();
      if (v === '') v = f.emptyNull ? null : '';
      payload[f.key] = v;
    });
    if (cfg.beforeSave) payload = cfg.beforeSave(payload, { isEdit, publicId: generatedId, record });

    if (isEdit) {
      window.MicosStore.update(cfg.collection, record.id, payload);
      onSaved(record.id);
    } else {
      // Sin id fabricado (ver header — mismo patrón que Expedientes/
      // Inventario): antes acá se armaba `base.id` con un esquema propio
      // ('ent-'+Date.now(), etc.), DISTINTO del `_tmp-…` que asigna
      // MicosStore.create() — el mismo bug de fondo del id-race. Ahora se
      // deja que el store asigne su id temporal; `created` es la MISMA
      // referencia que reconcilia in-place al volver el POST, y se navega
      // con `created.id`. El 3er argumento de create() (onReconciled,
      // core/store.js) avisa cuando el POST resuelve con el uuid real: si
      // para ese momento el usuario YA entró a Editar con el id temporal
      // (viejo), `onIdReconciled` corrige el id de selección en app.jsx —
      // sin esto, el guard de fetch-and-merge de arriba no alcanza (el id
      // temporal nunca existió en el server, un fetch por ese id 404 sí o
      // sí; verificado en vivo).
      const base = { __nuevo: true };
      if (autoId) base[idKey] = generatedId;
      const rec = Object.assign(base, payload);
      // `tempId` se captura en una variable propia (string, inmutable) ANTES
      // de que el POST pueda resolver (garantizado: create() es síncrono
      // hasta su `return`, la promesa recién puede asentar en un microtask
      // posterior) — si en vez de esto se leyera `created.id` DENTRO del
      // callback, para ese momento `Object.assign(it, server)` ya mutó ESE
      // MISMO objeto al uuid real, y `created.id` devolvería el id NUEVO,
      // no el viejo: el guard de app.jsx (`cur === oldId`) nunca matchearía
      // contra el id temporal real con el que se navegó (bug detectado en
      // vivo durante la verificación de este fix).
      let tempId;
      const created = window.MicosStore.create(cfg.collection, rec, (reconciled) => {
        // Se manda el id VIEJO (temporal, con el que se navegó) además del
        // real: app.jsx solo corrige su estado de selección si todavía
        // apunta a ESE registro — si el usuario ya se fue a ver/crear otra
        // cosa mientras tanto, no le pisamos la pantalla actual.
        if (onIdReconciled) onIdReconciled(tempId, reconciled.id);
      });
      tempId = created.id;
      try { localStorage.setItem('micos-just-created', created.id); } catch (_) {}
      onSaved(created.id);
    }
  };

  const handleDelete = () => {
    window.MicosStore.remove(cfg.collection, record.id);
    setDelOpen(false);
    if (onDeleted) onDeleted();
  };

  return (
    <div className="space-y-7 pt-2">
      {/* Header */}
      <div className="flex flex-wrap items-start justify-between gap-4">
        <div className="space-y-3 min-w-0">
          <button onClick={handleCancel}
            className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold transition-colors cursor-pointer"
            style={{ color: 'var(--muted-foreground)' }}
            onMouseEnter={(e) => e.currentTarget.style.color = 'var(--accent)'}
            onMouseLeave={(e) => e.currentTarget.style.color = 'var(--muted-foreground)'}>
            <IconArrowLeft size={14} /> {effectiveBackLabel}
          </button>
          <div className="flex items-center gap-3 flex-wrap">
            <span className="font-mono text-[13px] font-extrabold tracking-wider uppercase" style={{ color: 'var(--accent)' }}>
              {headerId}
            </span>
            <span className="text-[10.5px] font-bold uppercase tracking-[0.14em] px-2 py-1 rounded-md"
              style={{ background: 'color-mix(in oklab, var(--accent) 12%, transparent)', color: 'var(--accent)' }}>
              {isEdit ? 'Editando' : 'Nuevo'}
            </span>
          </div>
          <h1 key={titleText} className="text-[clamp(26px,4vw,38px)] leading-[1.05] font-extrabold tracking-[-0.02em] animate-name-pop"
            style={{ color: titleText && titleText !== cfg.titleNew ? 'var(--foreground)' : 'color-mix(in oklab, var(--muted-foreground) 75%, transparent)' }}>
            {titleText}
          </h1>
          <p className="text-[13.5px] font-medium max-w-2xl" style={{ color: 'var(--muted-foreground)' }}>
            {cfg.subtitle}
          </p>
        </div>
        <div className="flex items-center gap-2 flex-shrink-0 flex-wrap">
          {isEdit && (
            <>
              <button className="btn" onClick={() => setDelOpen(true)}
                style={{ background: 'var(--card)', color: 'var(--destructive)', border: '1px solid color-mix(in oklab, var(--destructive) 55%, var(--border))' }}>
                <IconTrash size={14} /><span className="hidden sm:inline">Eliminar</span>
              </button>
              <span aria-hidden="true" style={{ width: 1, height: 26, background: 'color-mix(in oklab, var(--border) 85%, transparent)', margin: '0 4px', flexShrink: 0 }} />
            </>
          )}
          <button className="btn btn-outline" onClick={handleCancel}>
            <IconX size={14} /><span className="hidden sm:inline">Descartar</span>
          </button>
          <button className="btn btn-primary" onClick={handleSave} style={!canSave ? { opacity: 0.55 } : {}}>
            <IconCheck size={14} /><span className="hidden sm:inline">{isEdit ? 'Guardar cambios' : 'Guardar'}</span>
          </button>
        </div>
      </div>

      {/* Secciones de campos */}
      <div className="space-y-6 stagger">
        {cfg.sections.map((sec, si) => (
          <div key={si} className="rounded-2xl bg-card p-6 sm:p-7" style={{
            border: '1px solid color-mix(in oklab, var(--border) 55%, transparent)',
            boxShadow: 'var(--shadow-card)',
          }}>
            <p className="text-[11px] font-bold uppercase tracking-[0.2em] mb-6" style={{ color: 'var(--accent)' }}>
              {sec.title}
            </p>
            <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-7 gap-y-5">
              {sec.fields.map((f) => (
                <RFField key={f.key} field={f} value={form[f.key]} onChange={set} error={touched ? errors[f.key] : null} />
              ))}
            </div>
          </div>
        ))}
      </div>

      {delOpen && (
        <ConfirmDeleteModal
          title={cfg.deleteTitle || '¿Eliminar registro?'}
          itemName={`${headerId}${titleText && titleText !== cfg.titleNew ? ' · ' + titleText : ''}`}
          description="Se va a borrar este registro. Esta acción no se puede deshacer."
          confirmLabel="Sí, eliminar"
          onCancel={() => setDelOpen(false)}
          onConfirm={handleDelete}
        />
      )}
    </div>
  );
}

// ─── Config por módulo ────────────────────────────────────────────────
const RECORD_FORMS = {
  // ── Directorio · Entidades ──────────────────────────────
  entidad: {
    collection: 'entidades',
    idKey: 'publicId', idPrefix: 'ENT-', idDigits: 8,
    backLabel: 'Volver a Entidades',
    titleNew: 'Nueva entidad',
    subtitle: 'Obra social, prepaga, sanatorio o clínica con convenio. Los expedientes vinculados se cuentan solos.',
    deleteTitle: '¿Eliminar entidad?',
    titleFrom: (f) => (f.razonSocial && f.razonSocial.trim()) ? f.razonSocial.trim() : '',
    // FIX hallazgo #20 (AUDITORIA-ui-decorativa-05-07-2026.md): antes fabricaba el default
    // fantasma 'Sin convenio' cuando el campo quedaba vacío — viajaba al backend, que lo
    // descartaba igual (ENTIDAD_COLUMNS no tenía 'convenio'), así que el usuario nunca lo
    // veía... hasta que la whitelist se arregló (route.ts) y hubiera empezado a persistir
    // 'Sin convenio' como si fuera un dato real cargado. Con la columna ya escribible, un
    // convenio vacío debe guardarse `null` (honesto: "sin convenio" se MUESTRA como '—' en
    // la UI de detalle, no se inventa un string).
    beforeSave: (p) => {
      p.expedientesActivos = p.expedientesActivos || 0;
      p.saldoCobrar = p.saldoCobrar || 0;
      return p;
    },
    sections: [
      {
        title: 'Datos de la entidad',
        fields: [
          { key: 'razonSocial', label: 'Razón social', type: 'text', required: true, span: 2 },
          // FIX (P10, mapa-comprension.md §otros, bug severidad baja): 'Mutual' NO existe en
          // el enum real de la DB (`entidades_tipo_check`: CLINICA|SANATORIO|HOSPITAL|
          // OBRA_SOCIAL|PREPAGA|OTRO, verificado `\d entidades`) ni en el mapeo label↔enum
          // (src/lib/api/enum-labels.ts ENUM_LABELS.tipo) — elegirlo mandaba el string literal
          // 'Mutual' al INSERT/UPDATE y violaba el CHECK constraint (400). Verificado también
          // en los datos reales (docker exec psql `select tipo, count(*) from entidades group
          // by tipo`: OTRO/OBRA_SOCIAL/CLINICA, ninguna fila 'MUTUAL') — no hay entidades tipo
          // Mutual que preservar. Se saca del form (no se agrega al enum real: la spec pide
          // sumarlo SOLO si existe en datos). De paso se agrega 'Otro' (faltaba en el form pese
          // a ser el valor MÁS COMÚN de los datos reales — 85 de 118 entidades — y ser un enum
          // real con label propio en ENUM_LABELS.tipo: sin esta opción, el alta no podía elegir
          // el tipo más frecuente del padrón).
          { key: 'tipo', label: 'Tipo', type: 'select', default: 'Obra social',
            options: ['Obra social', 'Prepaga', 'Sanatorio', 'Clínica', 'Hospital', 'Otro'] },
          { key: 'cuit', label: 'CUIT', type: 'text', required: true, mono: true, placeholder: '30-00000000-0' },
          { key: 'condicionIva', label: 'Condición IVA', type: 'select', required: true,
            options: ['Responsable Inscripto', 'Exento', 'Monotributo'] },
          { key: 'convenio', label: 'Convenio / Plan', type: 'text', emptyNull: true, placeholder: 'Ej: Convenio Pro-Salud 2025' },
          { key: 'condicionVenta', label: 'Condición de venta', type: 'select', default: 'CC 30 días',
            options: ['CC 30 días', 'CC 45 días', 'CC 60 días', 'Contado'] },
        ],
      },
      {
        title: 'Domicilio y contacto',
        fields: [
          { key: 'domicilio', label: 'Domicilio', type: 'text', span: 2, emptyNull: true },
          { key: 'localidad', label: 'Localidad', type: 'text', emptyNull: true },
          { key: 'provincia', label: 'Provincia', type: 'text', emptyNull: true },
          { key: 'telefono', label: 'Teléfono', type: 'text', mono: true, emptyNull: true, placeholder: '+54 …' },
          { key: 'email', label: 'Email', type: 'text', emptyNull: true, placeholder: 'facturacion@…' },
        ],
      },
    ],
  },

  // ── Directorio · Médicos ────────────────────────────────
  medico: {
    collection: 'medicos',
    idKey: 'publicId', idPrefix: 'MED-', idDigits: 8,
    backLabel: 'Volver a Médicos',
    titleNew: 'Nuevo médico',
    subtitle: 'Profesional que deriva expedientes u opera. El nombre va sin título; el título va aparte.',
    deleteTitle: '¿Eliminar médico?',
    titleFrom: (f) => {
      const n = `${f.nombre || ''} ${f.apellido || ''}`.trim();
      return n ? `${f.titulo ? f.titulo + ' ' : ''}${n}` : '';
    },
    beforeSave: (p) => {
      p.especialidad = p.especialidad || '';
      p.especialidades = p.especialidad ? [p.especialidad] : [];
      p.expedientes = p.expedientes || 0;
      p.derivaciones = p.derivaciones || 0;
      return p;
    },
    sections: [
      {
        title: 'Datos del médico',
        fields: [
          { key: 'titulo', label: 'Título', type: 'select', emptyNull: true, options: ['Dr.', 'Dra.'] },
          { key: 'matricula', label: 'Matrícula', type: 'text', mono: true, placeholder: 'MN 00.000' },
          { key: 'nombre', label: 'Nombre', type: 'text', required: true },
          { key: 'apellido', label: 'Apellido', type: 'text', required: true },
          { key: 'especialidad', label: 'Especialidad', type: 'select', span: 2,
            // P5: fallback duplicado (con el orden cambiado respecto al canónico)
            // borrado — window.ESPECIALIDADES ya se expone de una vez (dominio/pipeline.js).
            options: () => (window.ESPECIALIDADES || []) },
          { key: 'dni', label: 'DNI', type: 'text', mono: true, emptyNull: true, placeholder: '00.000.000' },
          { key: 'telefono', label: 'Teléfono', type: 'text', mono: true, emptyNull: true, placeholder: '+54 …' },
          { key: 'email', label: 'Email', type: 'text', emptyNull: true, placeholder: 'medico@…' },
        ],
      },
    ],
  },

  // ── Directorio · Particulares (pacientes) ───────────────
  particular: {
    collection: 'particulares',
    idKey: 'publicId', idPrefix: 'PAC-', idDigits: 8,
    backLabel: 'Volver a Particulares',
    titleNew: 'Nuevo paciente',
    // FIX hallazgo #19: "la cobertura ... se asocia después desde su detalle" dejó de ser
    // cierto — ahora se elige acá mismo (select 'Cobertura' real, ver más abajo). Los
    // expedientes siguen asociándose después (no hay alta de expediente desde este form).
    subtitle: 'Paciente registrado. Los expedientes se asocian después desde su detalle.',
    deleteTitle: '¿Eliminar paciente?',
    titleFrom: (f) => `${f.nombre || ''} ${f.apellido || ''}`.trim(),
    beforeSave: (p) => {
      p.expedientes = p.expedientes || 0;
      p.estado = p.estado || 'ACTIVO';
      p.ultimaActividad = p.ultimaActividad || rfTodayISO();
      return p;
    },
    sections: [
      {
        title: 'Datos personales',
        fields: [
          { key: 'nombre', label: 'Nombre', type: 'text', required: true },
          { key: 'apellido', label: 'Apellido', type: 'text', required: true },
          // P16 (Fran, 03/07): directorio legacy mínimo — solo nombre/apellido obligatorios.
          // El DNI sigue en el form (campo real de la tabla, útil cuando se tiene), pero ya
          // NO bloquea el alta: la DB solo exige nombre/apellido NOT NULL, y médicos (arriba)
          // ya no lo exigía tampoco — quedaba asimétrico sin ninguna razón de negocio.
          { key: 'dni', label: 'DNI', type: 'text', mono: true, emptyNull: true, placeholder: '00.000.000' },
          { key: 'fechaNacimiento', label: 'Fecha de nacimiento', type: 'date', mono: true, emptyNull: true },
          { key: 'sexo', label: 'Sexo', type: 'select', emptyNull: true,
            options: [{ value: 'F', label: 'Femenino' }, { value: 'M', label: 'Masculino' }] },
          // FIX hallazgo #19 (AUDITORIA-ui-decorativa-05-07-2026.md): el select ofrecía una
          // lista de planes de texto libre inventados ('Galeno Plata', 'OSDE Binario', ...)
          // que no correspondían a NINGUNA entidad real ni tenían dónde persistir (la
          // cobertura real vive en `particular_cobertura`, FK a `entidades`, sin ruta de
          // escritura). El resto de la pantalla YA trata "Cobertura" como un vínculo a una
          // ENTIDAD real (screens/directorio/particulares.jsx: `p.coberturaActual.entidadId`
          // resuelto contra `window.ENTIDADES` en la lista, el detalle y el historial) — el
          // fix alinea el alta/edición al mismo modelo: elegís una entidad real del padrón
          // (misma fuente que ya hidrata toda la pantalla), no un string suelto. `key` pasa
          // de 'cobertura' a 'entidadId' (nada más leía el campo viejo — era 100% decorativo)
          // y `fromRecord` (buildForm, arriba) lo pre-llena desde `coberturaActual.entidadId`
          // en edición, que antes quedaba siempre en blanco pese a que el GET ya traía el dato.
          { key: 'entidadId', label: 'Cobertura', type: 'select', emptyNull: true,
            options: () => (window.ENTIDADES || []).map((e) => ({ value: e.id, label: e.razonSocial })),
            fromRecord: (rec) => (rec.coberturaActual && rec.coberturaActual.entidadId) || '' },
        ],
      },
      {
        title: 'Contacto',
        fields: [
          { key: 'telefono', label: 'Teléfono', type: 'text', mono: true, emptyNull: true, placeholder: '+54 …' },
          { key: 'email', label: 'Email', type: 'text', emptyNull: true, placeholder: 'nombre@correo.com' },
          { key: 'domicilio', label: 'Domicilio', type: 'text', span: 2, emptyNull: true },
          { key: 'localidad', label: 'Localidad', type: 'text', emptyNull: true },
          { key: 'provincia', label: 'Provincia', type: 'text', emptyNull: true },
        ],
      },
    ],
  },

  // ── Directorio · Proveedores ────────────────────────────
  proveedor: {
    collection: 'proveedores',
    idKey: 'publicId', idPrefix: 'PRV-', idDigits: 8,
    backLabel: 'Volver a Proveedores',
    titleNew: 'Nuevo proveedor',
    subtitle: 'A quién le compramos insumos e implantes. Se referencia desde Compras y Pagos.',
    deleteTitle: '¿Eliminar proveedor?',
    titleFrom: (f) => (f.razonSocial && f.razonSocial.trim()) ? f.razonSocial.trim() : '',
    beforeSave: (p) => { p.estado = p.estado || 'ACTIVO'; p.saldoPagar = p.saldoPagar || 0; return p; },
    sections: [
      {
        title: 'Datos del proveedor',
        fields: [
          { key: 'razonSocial', label: 'Razón social', type: 'text', required: true, span: 2 },
          { key: 'cuit', label: 'CUIT', type: 'text', required: true, mono: true, placeholder: '30-00000000-0' },
          { key: 'condicionIva', label: 'Condición IVA', type: 'select', required: true,
            options: ['Responsable Inscripto', 'Exento', 'Monotributo'] },
          { key: 'condicionVenta', label: 'Condición de venta', type: 'select', default: 'CC 30 días',
            options: ['CC 30 días', 'CC 45 días', 'CC 60 días', 'Contado'] },
          { key: 'rubro', label: 'Rubro', type: 'select', emptyNull: true,
            options: ['Implantes', 'Tornillería', 'Instrumental', 'Insumos', 'Ortopedia', 'Otros'] },
        ],
      },
      {
        title: 'Domicilio y contacto',
        fields: [
          { key: 'domicilio', label: 'Domicilio', type: 'text', span: 2, emptyNull: true },
          { key: 'localidad', label: 'Localidad', type: 'text', emptyNull: true },
          { key: 'provincia', label: 'Provincia', type: 'text', emptyNull: true },
          { key: 'telefono', label: 'Teléfono', type: 'text', mono: true, emptyNull: true, placeholder: '+54 …' },
          { key: 'email', label: 'Email', type: 'text', emptyNull: true, placeholder: 'pedidos@…' },
        ],
      },
    ],
  },
};

// ─── RecordFormModal (P18) ─────────────────────────────────────────────
// Overlay que monta RecordFormScreen en modo alta (recordId siempre null)
// para altas disparadas desde OTRA pantalla que no puede perder su propio
// contexto — ver header del archivo. `type`: llave de RECORD_FORMS
// ('entidad'|'medico'|'particular'|'proveedor'). `onCreated(id)` recibe el
// id con el que RecordFormScreen llamó a onSaved (mismo contrato: temporal
// `_tmp-…` si el POST todavía no resolvió, o el uuid real si ya volvió —
// MicosStore.create() muta el MISMO objeto en window.<COLECCION>, así que
// el caller puede leerlo por ese id apenas este callback dispara). Chrome
// visual calcado de PartySelectorModal (mismos colores/blur ya en uso, sin
// inventar tokens nuevos — regla de diseño §2/§0 de public/erp/CLAUDE.md).
function RecordFormModal({ type, open, onClose, onCreated }) {
  _useEffectRF(() => {
    if (!open) return undefined;
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    document.addEventListener('keydown', onKey);
    const prev = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    return () => { document.removeEventListener('keydown', onKey); document.body.style.overflow = prev; };
  }, [open, onClose]);

  if (!open || !type) return null;
  const cfg = RECORD_FORMS[type];

  return (
    <div
      className="fixed inset-0 z-50 flex items-start sm:items-center justify-center p-4 sm:p-6 overflow-y-auto"
      style={{ background: 'color-mix(in oklab, #0a1418 62%, transparent)', backdropFilter: 'blur(4px)' }}
      onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
    >
      <div
        className="w-full max-w-2xl rounded-2xl my-auto animate-fade-in-up"
        style={{ background: 'var(--card)', border: '1px solid color-mix(in oklab, var(--border) 65%, transparent)', boxShadow: '0 30px 80px -20px rgba(8, 20, 24, 0.55)' }}
        onMouseDown={(e) => e.stopPropagation()}
      >
        <div className="p-5 sm:p-6" style={{ maxHeight: '85vh', overflowY: 'auto' }}>
          <RecordFormScreen
            config={cfg}
            recordId={null}
            backLabel="Cancelar"
            onCancel={onClose}
            onSaved={(id) => onCreated(id)}
          />
        </div>
      </div>
    </div>
  );
}

Object.assign(window, { RecordFormScreen, RECORD_FORMS, RecordFormModal });
