// ─────────────────────────────────────────────
// DO_UploadFields.jsx
// Three field components for file storage:
//   ImageField   — single profile image
//   GalleryField — thumbnail grid + lightbox
//   FileField    — file list with icons
//   CameraField  — direct camera capture
//
// All use presigned S3 upload via:
//   POST /v6/upload/init  → get presigned URL
//   PUT  presignedUrl     → upload directly to S3
//   POST /v6/upload/done  → save metadata to DB
//   DELETE /v6/upload/file → remove file
//
// Changes:
//   - Gallery thumbnail fix: show placeholder until pollForThumb
//     confirms _th.webp exists, then swap src — prevents broken
//     image flash after upload while Sharp is still processing
//   - Lucide Camera + FileUp icons replace emoji in all three
//     field type action buttons (camera trigger + upload trigger)
// ─────────────────────────────────────────────

const { useState, useRef, useCallback } = React;

// ── Lucide icon helpers ───────────────────────
// Inline SVG wrappers — no import needed, CDN not required
function IconCamera({ size = 16, color = 'currentColor', strokeWidth = 2 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none"
      stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round">
      <path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/>
      <circle cx="12" cy="13" r="3"/>
    </svg>
  );
}

function IconFileUp({ size = 16, color = 'currentColor', strokeWidth = 2 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none"
      stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round">
      <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
      <polyline points="14 2 14 8 20 8"/>
      <line x1="12" y1="12" x2="12" y2="18"/>
      <polyline points="9 15 12 12 15 15"/>
    </svg>
  );
}

// ─────────────────────────────────────────────
// SHARED UPLOAD ENGINE
// ─────────────────────────────────────────────
async function uploadFile(file, { appId, table, recordId, field, fieldType, onProgress }) {
  // Step 1 — get presigned URL
  const initRes = await apiFetch(`${API_BASE}/v6/upload/init`, {
    method:  'POST',
    headers: { 'Content-Type': 'application/json' },
    body:    JSON.stringify({
      app:       appId,
      table,
      record:    recordId,
      field,
      fieldType,
      filename:  file.name,
      size:      file.size,
      mime:      file.type,
    }),
  });
  const init = await initRes.json();
  if (init.status !== 'ok') throw new Error(init.message);

  // Step 2 — upload directly to S3 via presigned URL
  await new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('PUT', init.uploadUrl);
    xhr.setRequestHeader('Content-Type', file.type);
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) onProgress && onProgress(Math.round(e.loaded / e.total * 100));
    };
    xhr.onload  = () => xhr.status === 200 ? resolve() : reject(new Error(`S3 upload failed: ${xhr.status}`));
    xhr.onerror = () => reject(new Error('S3 upload network error'));
    xhr.send(file);
  });

  // Step 3 — notify server, save metadata to DB
  const doneRes = await apiFetch(`${API_BASE}/v6/upload/done`, {
    method:  'POST',
    headers: { 'Content-Type': 'application/json' },
    body:    JSON.stringify({
      app:      appId,
      table,
      record:   recordId,
      field,
      fileId:   init.fileId,
      filename: file.name,
      size:     file.size,
      mime:     file.type,
      ext:      init.ext,
      category: init.category,
      s3Key:    init.s3Key,
    }),
  });
  const done = await doneRes.json();
  if (done.status !== 'ok') throw new Error(done.message);

  return { ...init, ...done };
}

// ─────────────────────────────────────────────
// URL HELPERS
// ─────────────────────────────────────────────
const LEGACY_BUCKET_MAP = {
  'va.us.s3.dataobjects.com': { region: 'us-east-1' },
  'or.us.s3.dataobjects.com': { region: 'us-west-2' },
  'ca.s3.dataobjects.com':    { region: 'ca-central-1' },
  'uk.s3.dataobjects.com':    { region: 'eu-west-2' },
};

function s3Url(bucket, key) {
  if (!bucket || !key) { console.warn('s3Url missing:', { bucket, key }); return ''; }
  if (bucket.startsWith('http')) return `${bucket}/${key}`;
  const info = LEGACY_BUCKET_MAP[bucket];
  const region = info ? info.region : 'us-east-1';
  return `https://s3.${region}.amazonaws.com/${bucket}/${key}`;
}
function thumbUrl(bucket, key, ext) {
  if (!key) return '';
  const base = ext ? key.slice(0, key.lastIndexOf(`.${ext}`)) : key;
  return s3Url(bucket, `${base}_th.webp`);
}
function medUrl(bucket, key, ext) {
  if (!key) return '';
  const base = ext ? key.slice(0, key.lastIndexOf(`.${ext}`)) : key;
  return s3Url(bucket, `${base}_md.webp`);
}

// ─────────────────────────────────────────────
// THUMBNAIL POLLER
// Polls S3 HEAD request until _th.webp appears (max 30s)
// Used after upload to wait for Sharp to finish processing
// ─────────────────────────────────────────────
async function pollForThumb(bucket, key, ext, onReady, maxWait = 30000) {
  const base = key.slice(0, key.lastIndexOf(`.${ext}`));
  const url  = `${bucket}/${base}_th.webp`;
  const start = Date.now();
  while (Date.now() - start < maxWait) {
    await new Promise(r => setTimeout(r, 1500));
    try {
      const res = await fetch(url, { method: 'HEAD' });
      if (res.ok) { onReady(); return; }
    } catch(e) { /* still waiting */ }
  }
}

// ─────────────────────────────────────────────
// FILE TYPE HELPERS
// ─────────────────────────────────────────────
const PREVIEW_EXTS = ['jpg','jpeg','png','gif','webp','pdf','svg','mp4','mov','webm'];
function canPreview(ext) { return PREVIEW_EXTS.includes((ext||'').toLowerCase()); }

function fileIcon(ext) {
  const e = (ext || '').toLowerCase();
  if (['jpg','jpeg','png','gif','webp','avif'].includes(e)) return '🖼';
  if (['mp4','mov','avi','mkv','webm','m4v'].includes(e))   return '🎬';
  if (['mp3','wav','aac','flac','ogg'].includes(e))          return '🎵';
  if (['pdf'].includes(e))                                   return '📄';
  if (['doc','docx'].includes(e))                            return '📝';
  if (['xls','xlsx','csv'].includes(e))                      return '📊';
  if (['ppt','pptx'].includes(e))                            return '📑';
  if (['zip','rar','gz','7z'].includes(e))                   return '🗜';
  if (['txt','md'].includes(e))                              return '📃';
  return '📎';
}

function formatSize(bytes) {
  const b = parseInt(bytes) || 0;
  if (b < 1024)        return `${b} B`;
  if (b < 1024*1024)   return `${(b/1024).toFixed(1)} KB`;
  if (b < 1024**3)     return `${(b/1024/1024).toFixed(1)} MB`;
  return `${(b/1024/1024/1024).toFixed(2)} GB`;
}

// ─────────────────────────────────────────────
// ACTION BUTTONS
// Camera and Upload — Lucide icons, bordered, right-aligned
// ─────────────────────────────────────────────
const btnStyle = {
  background: '#fff',
  border: '1.5px solid #cbd5e1',
  borderRadius: 5,
  padding: '4px 8px',
  cursor: 'pointer',
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'center',
  color: '#475569',
  lineHeight: 1,
};

function CameraTrigger({ onFiles, capture }) {
  const ref = React.useRef(null);
  return (
    <>
      <input ref={ref} type="file" accept="image/*"
        capture={capture || 'environment'}
        style={{ display: 'none' }}
        onChange={e => {
          const files = Array.from(e.target.files || []);
          e.target.value = '';
          if (files.length) onFiles(files);
        }} />
      <button type="button" onClick={() => ref.current?.click()}
        title="Take photo" style={btnStyle}>
        <IconCamera size={15} />
      </button>
    </>
  );
}

function UploadTrigger({ label, accept, multiple, disabled, onFiles }) {
  const inputRef = useRef(null);
  return (
    <>
      <button
        style={{ ...btnStyle, opacity: disabled ? 0.5 : 1 }}
        onClick={() => inputRef.current?.click()}
        disabled={disabled}
        title={label}
      >
        <IconFileUp size={15} />
      </button>
      <input
        ref={inputRef}
        type="file"
        accept={accept}
        multiple={multiple}
        style={{ display: 'none' }}
        onChange={e => {
          const files = Array.from(e.target.files || []);
          e.target.value = '';
          if (files.length) onFiles(files);
        }}
      />
    </>
  );
}

// Progress bar
function ProgressBar({ pct, filename }) {
  return (
    <div style={SU.progressWrap}>
      <div style={SU.progressLabel}>{filename} — {pct}%</div>
      <div style={SU.progressTrack}>
        <div style={{ ...SU.progressFill, width: `${pct}%` }} />
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────
// IMAGE FIELD
// ─────────────────────────────────────────────
function ImageField({ obj, value, appId, table, recordId, bucket: bucketProp, onSaved }) {
  const parsed   = Array.isArray(value) ? value : (value ? tryParse(value) : []);
  const [localFiles, setLocalFiles] = useState(parsed);
  const [idx,    setIdx]    = useState(0);
  const [uploads, setUploads] = useState([]);
  const [error,  setError]  = useState(null);
  const [hovered, setHovered] = useState(false);

  React.useEffect(() => {
    const updated = Array.isArray(value) ? value : (value ? tryParse(value) : []);
    setLocalFiles(updated);
    setIdx(0);
  }, [value]);

  const files   = localFiles;
  const current = files[idx] || null;
  const bucket  = bucketProp || 'https://east.dataobjects.com';

  const handleFiles = async (selected) => {
    setError(null);
    for (const file of selected) {
      const id = Date.now();
      setUploads(u => [...u, { id, name: file.name, pct: 0 }]);
      try {
        const result = await uploadFile(file, {
          appId, table, recordId,
          field:     obj.field,
          fieldType: 'image',
          onProgress: pct => setUploads(u => u.map(x => x.id === id ? { ...x, pct } : x)),
        });
        setUploads(u => u.filter(x => x.id !== id));
        onSaved && onSaved(obj.field);
        if (result?.ext && ['jpg','jpeg','png','gif','webp'].includes(result.ext)) {
          pollForThumb(bucket, result.s3Key, result.ext, () => onSaved && onSaved(obj.field));
        }
      } catch(err) {
        setError(err.message);
        setUploads(u => u.filter(x => x.id !== id));
      }
    }
  };

  const handleDelete = async () => {
    if (!current || !window.confirm('Delete this image?')) return;
    try {
      const res = await apiFetch(
        `${API_BASE}/v6/upload/file?app=${appId}&table=${table}&record=${recordId}&field=${obj.field}&fileId=${current.ID}`,
        { method: 'DELETE' }
      );
      const data = await res.json();
      if (data.status !== 'ok') throw new Error(data.message);
      setIdx(0);
      onSaved && onSaved(obj.field);
    } catch(err) { setError(err.message); }
  };

  const imgWidth    = obj.width     || '100%';
  const imgMaxH     = obj.maxHeight ? parseInt(obj.maxHeight) : 300;
  const imgAlign    = obj.align     || 'left';
  const allowUpload  = obj.allowUpload  !== false;
  const allowCamera  = obj.allowCamera === true || obj.allowCamera === 'true' || obj.allowCamera === 'yes';
  const allowDownload= obj.allowDownload !== false;
  const allowDelete  = obj.allowDelete   !== false;

  const [isMobile, setIsMobile] = React.useState(
    typeof window !== 'undefined' && window.matchMedia('(hover: none)').matches
  );
  React.useEffect(() => {
    const mq = window.matchMedia('(hover: none)');
    const handler = e => setIsMobile(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, []);
  const showActions = isMobile || hovered;

  const alignStyle = imgAlign === 'center' ? { textAlign: 'center' }
    : imgAlign === 'right'  ? { textAlign: 'right' }
    : {};

  return (
    <div style={SU.fieldWrap}>
      <div style={SU.labelRow}>
        <label style={SU.label}>{obj.label || 'Image'}</label>
        <div style={{ display: 'flex', gap: 4 }}>
          {allowCamera && (
            <CameraTrigger onFiles={handleFiles} capture={obj.capture || 'environment'} />
          )}
          {allowUpload && (
            <UploadTrigger label="Upload" accept="image/*" multiple={false} onFiles={handleFiles} />
          )}
        </div>
      </div>

      {error && <div style={SU.errorMsg}>{error}</div>}
      {uploads.map(u => <ProgressBar key={u.id} pct={u.pct} filename={u.name} />)}

      {current ? (
        <div style={{ ...alignStyle }}>
          <div
            style={{ ...SU.imageWrap, display: 'inline-block', width: imgWidth, maxWidth: '100%' }}
            onMouseEnter={() => setHovered(true)}
            onMouseLeave={() => setHovered(false)}>
            <img
              src={medUrl(bucket, current.KEY, current.EXT)}
              alt={current.NAME}
              style={{ ...SU.image, maxHeight: imgMaxH + 'px' }}
              onError={e => {
                if (e.target.dataset.errored) return;
                e.target.dataset.errored = '1';
                const orig = s3Url(bucket, current.KEY);
                if (e.target.src !== orig) e.target.src = orig;
                else e.target.style.display = 'none';
              }}
            />
            {files.length > 1 && showActions && idx > 0 && (
              <div onClick={() => setIdx(i => i - 1)}
                style={{ position:'absolute', left:0, top:0, bottom:0, width:'40%',
                  display:'flex', alignItems:'center', paddingLeft:8, cursor:'pointer', zIndex:2 }}>
                <div style={{ width:32, height:32, borderRadius:'50%',
                  background:'rgba(0,0,0,0.35)', display:'flex', alignItems:'center',
                  justifyContent:'center', color:'#fff', fontSize:20, backdropFilter:'blur(4px)',
                  pointerEvents:'none' }}>‹</div>
              </div>
            )}
            {files.length > 1 && showActions && idx < files.length - 1 && (
              <div onClick={() => setIdx(i => i + 1)}
                style={{ position:'absolute', right:0, top:0, bottom:0, width:'40%',
                  display:'flex', alignItems:'center', justifyContent:'flex-end',
                  paddingRight:8, cursor:'pointer', zIndex:2 }}>
                <div style={{ width:32, height:32, borderRadius:'50%',
                  background:'rgba(0,0,0,0.35)', display:'flex', alignItems:'center',
                  justifyContent:'center', color:'#fff', fontSize:20, backdropFilter:'blur(4px)',
                  pointerEvents:'none' }}>›</div>
              </div>
            )}
            {showActions && (
              <div style={{ position:'absolute', bottom:0, left:0, right:0,
                padding:'6px 8px',
                background:'linear-gradient(transparent, rgba(0,0,0,0.55))',
                display:'flex', alignItems:'center', gap:6, zIndex:3 }}>
                {files.length > 1 && (
                  <span style={{ fontSize:11, color:'rgba(255,255,255,0.7)',
                    fontFamily:'DM Mono, monospace', marginRight:'auto' }}>
                    {idx + 1} / {files.length}
                  </span>
                )}
                {!(files.length > 1) && <span style={{ flex:1 }} />}
                {allowDownload && (
                  <a href={s3Url(bucket, current.KEY)} target="_blank"
                    rel="noopener noreferrer" title="Download original"
                    style={{ width:30, height:30, borderRadius:5, background:'rgba(255,255,255,0.2)',
                      color:'#fff', display:'flex', alignItems:'center', justifyContent:'center',
                      textDecoration:'none', fontSize:15, backdropFilter:'blur(4px)' }}>⬇</a>
                )}
                {allowDelete && (
                  <button onClick={handleDelete} title="Delete image"
                    style={{ width:30, height:30, borderRadius:5, background:'rgba(220,38,38,0.4)',
                      border:'none', color:'#fff', cursor:'pointer', fontSize:14,
                      display:'flex', alignItems:'center', justifyContent:'center',
                      backdropFilter:'blur(4px)' }}>✕</button>
                )}
              </div>
            )}
          </div>
        </div>
      ) : (
        <div style={SU.imagePlaceholder}>No image</div>
      )}
    </div>
  );
}

// ─────────────────────────────────────────────
// LIGHTBOX
// ─────────────────────────────────────────────
function LightBox({ files, index, bucket, allowDelete, onNavigate, onClose, onDelete, thumbErrors, setThumbErrors }) {
  const [isPlaying,  setIsPlaying]  = React.useState(false);
  const [isFullscr,  setIsFullscr]  = React.useState(false);
  const slideTimer                  = React.useRef(null);
  const total                       = files.length;
  const lb = files[index] || null;

  function nav(dir) {
    onNavigate(i => {
      let next = i + dir;
      if (next < 0)      next = total - 1;
      if (next >= total) next = 0;
      return next;
    });
  }

  React.useEffect(() => {
    function onKey(e) {
      if (e.key === 'ArrowLeft')  { e.stopPropagation(); nav(-1); }
      if (e.key === 'ArrowRight') { e.stopPropagation(); nav(+1); }
      if (e.key === 'Escape')     { e.stopPropagation(); handleClose(); }
    }
    window.addEventListener('keydown', onKey, true);
    return () => window.removeEventListener('keydown', onKey, true);
  }, [total]);

  React.useEffect(() => {
    if (isPlaying) {
      slideTimer.current = setInterval(() => nav(+1), 3000);
    } else {
      clearInterval(slideTimer.current);
    }
    return () => clearInterval(slideTimer.current);
  }, [isPlaying, total]);

  function handleClose() {
    setIsPlaying(false);
    clearInterval(slideTimer.current);
    onClose();
  }

  function toggleFullscreen() {
    const el = document.getElementById('do-lightbox');
    if (!el) return;
    if (!isFullscr) el.requestFullscreen?.();
    else document.exitFullscreen?.();
    setIsFullscr(f => !f);
  }

  if (!lb) return null;

  const imgSrc = s3Url(bucket, lb.KEY);
  const medSrc = medUrl(bucket, lb.KEY, lb.EXT);

  const overlayStyle = {
    position:'fixed', inset:0, zIndex:9000,
    background: isFullscr ? 'rgba(0,0,0,1)' : 'rgba(0,0,0,0.88)',
    display:'flex', flexDirection:'column',
  };

  const iconBtn = (title, content, onClick, active) => (
    <button onClick={onClick} title={title}
      style={{ background: active ? 'rgba(255,255,255,0.25)' : 'rgba(255,255,255,0.1)',
        border:'none', color:'#fff', width:36, height:36, borderRadius:6,
        cursor:'pointer', fontSize:16, display:'flex', alignItems:'center',
        justifyContent:'center', transition:'background 0.15s' }}
      onMouseEnter={e => e.currentTarget.style.background='rgba(255,255,255,0.25)'}
      onMouseLeave={e => e.currentTarget.style.background = active ? 'rgba(255,255,255,0.25)' : 'rgba(255,255,255,0.1)'}>
      {content}
    </button>
  );

  return (
    <div id="do-lightbox" style={overlayStyle}>
      <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between',
        padding:'10px 16px', flexShrink:0, zIndex:1 }}>
        <span style={{ color:'rgba(255,255,255,0.6)', fontSize:13 }}>
          {index + 1} / {total}
        </span>
        <div style={{ display:'flex', gap:8 }}>
          {iconBtn('Download original', '⬇', () => window.open(imgSrc, '_blank'), false)}
          {iconBtn(isPlaying ? 'Pause' : 'Play slideshow', isPlaying ? '⏸' : '▶', () => setIsPlaying(p => !p), isPlaying)}
          {iconBtn(isFullscr ? 'Exit fullscreen' : 'Fullscreen', '⛶', toggleFullscreen, isFullscr)}
          {iconBtn('Close', '✕', handleClose, false)}
        </div>
      </div>
      <div style={{ flex:1, display:'flex', alignItems:'center', minHeight:0, position:'relative' }}>
        <div onClick={() => nav(-1)}
          style={{ position:'absolute', left:0, top:0, bottom:0, width:'50%',
            cursor:'default', zIndex:1, display:'flex', alignItems:'center', justifyContent:'flex-start' }}>
          <div style={{ marginLeft:16, width:44, height:44, borderRadius:'50%',
            background:'rgba(255,255,255,0.15)', display:'flex', alignItems:'center',
            justifyContent:'center', fontSize:22, color:'#fff', pointerEvents:'none',
            backdropFilter:'blur(4px)' }}>‹</div>
        </div>
        <img
          src={thumbErrors?.[lb.ID] ? imgSrc : medSrc}
          alt={lb.NAME}
          style={{ maxWidth:'90%', maxHeight:'100%', objectFit:'contain',
            display:'block', margin:'0 auto', borderRadius:4,
            boxShadow:'0 8px 40px rgba(0,0,0,0.5)', pointerEvents:'none' }}
          onError={e => {
            if (e.target.dataset.errored) return;
            e.target.dataset.errored = '1';
            if (e.target.src !== imgSrc) e.target.src = imgSrc;
          }}
          onLoad={() => {
            if (lb?.ID && setThumbErrors)
              setThumbErrors(prev => { const n={...prev}; delete n[lb.ID]; return n; });
          }}
        />
        <div onClick={() => nav(+1)}
          style={{ position:'absolute', right:0, top:0, bottom:0, width:'50%',
            cursor:'default', zIndex:1, display:'flex', alignItems:'center', justifyContent:'flex-end' }}>
          <div style={{ marginRight:16, width:44, height:44, borderRadius:'50%',
            background:'rgba(255,255,255,0.15)', display:'flex', alignItems:'center',
            justifyContent:'center', fontSize:22, color:'#fff', pointerEvents:'none',
            backdropFilter:'blur(4px)' }}>›</div>
        </div>
      </div>
      <div style={{ padding:'10px 16px', textAlign:'center', flexShrink:0 }}>
        <span style={{ color:'rgba(255,255,255,0.55)', fontSize:12, fontFamily:'DM Mono, monospace' }}>
          {lb.NAME}.{lb.EXT} · {formatSize(lb.SIZE)}
        </span>
        {allowDelete && (
          <button onClick={e => { onDelete(lb, e); }}
            style={{ marginLeft:16, background:'rgba(220,38,38,0.3)', border:'1px solid rgba(220,38,38,0.5)',
              color:'#fca5a5', fontSize:11, padding:'3px 10px', borderRadius:4, cursor:'pointer',
              fontFamily:'inherit' }}>
            Delete
          </button>
        )}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────
// GALLERY FIELD
// Thumbnail grid + lightbox
//
// THUMBNAIL FIX:
// After upload, onSaved triggers fetchRecord which adds the new
// file entry to localFiles. We track "pending thumbnails" by
// fileId — while pending, show a grey placeholder instead of
// trying the _th.webp URL (which doesn't exist yet while Sharp
// is processing). pollForThumb waits until the file exists on
// S3, then clears the pending flag and React re-renders with
// the real thumb URL. No broken image flash.
// ─────────────────────────────────────────────
function GalleryField({ obj, value, appId, table, recordId, bucket: bucketProp, onSaved }) {
  const parsed   = Array.isArray(value) ? value : (value ? tryParse(value) : []);
  const [localFiles,    setLocalFiles]    = useState(parsed);
  const [lightbox,      setLightbox]      = useState(null);
  const [uploads,       setUploads]       = useState([]);
  const [error,         setError]         = useState(null);
  const [thumbErrors,   setThumbErrors]   = useState({}); // fileId → true if thumb failed to load
  const [thumbPending,  setThumbPending]  = useState({}); // fileId → true while Sharp is processing

  React.useEffect(() => {
    setLocalFiles(Array.isArray(value) ? value : (value ? tryParse(value) : []));
  }, [value]);

  const files       = localFiles;
  const bucket      = bucketProp || 'https://east.dataobjects.com';
  const thumbSize   = parseInt(obj.thumbSize)  || 100;
  const displayRows = parseInt(obj.displayRows) || 0;
  const allowUpload = obj.allowUpload  !== false;
  const allowCamera = obj.allowCamera === true || obj.allowCamera === 'true' || obj.allowCamera === 'yes';
  const allowDelete = obj.allowDelete  !== false;
  const gridMaxH    = displayRows ? (displayRows * (thumbSize + 8)) : undefined;

  const handleFiles = async (selected) => {
    setError(null);
    for (const file of selected) {
      const id = Date.now() + Math.random();
      setUploads(u => [...u, { id, name: file.name, pct: 0 }]);
      try {
        const result = await uploadFile(file, {
          appId, table, recordId,
          field:     obj.field,
          fieldType: 'gallery',
          onProgress: pct => setUploads(u => u.map(x => x.id === id ? { ...x, pct } : x)),
        });
        setUploads(u => u.filter(x => x.id !== id));

        // Mark this fileId as pending BEFORE onSaved triggers fetchRecord
        // so when the new thumb entry appears in localFiles, it shows placeholder
        if (result?.fileId && result?.ext && ['jpg','jpeg','png','gif','webp'].includes(result.ext)) {
          setThumbPending(prev => ({ ...prev, [result.fileId]: true }));
          // Poll S3 until _th.webp exists, then clear pending flag → React re-renders with real thumb
          pollForThumb(bucket, result.s3Key, result.ext, () => {
            setThumbPending(prev => { const n = {...prev}; delete n[result.fileId]; return n; });
            setThumbErrors(prev =>  { const n = {...prev}; delete n[result.fileId]; return n; });
          });
        }

        onSaved && onSaved(obj.field);
      } catch(err) {
        setError(err.message);
        setUploads(u => u.filter(x => x.id !== id));
      }
    }
  };

  const handleDelete = async (file, e) => {
    e.stopPropagation();
    if (!window.confirm(`Delete ${file.NAME}.${file.EXT}?`)) return;
    try {
      const res = await apiFetch(
        `${API_BASE}/v6/upload/file?app=${appId}&table=${table}&record=${recordId}&field=${obj.field}&fileId=${file.ID}`,
        { method: 'DELETE' }
      );
      const data = await res.json();
      if (data.status !== 'ok') throw new Error(data.message);
      setLightbox(null);
      onSaved && onSaved(obj.field);
    } catch(err) { setError(err.message); }
  };

  const lb = lightbox !== null ? files[lightbox] : null;

  return (
    <div style={SU.fieldWrap}>
      <div style={SU.labelRow}>
        <label style={SU.label}>{obj.label || 'Gallery'}</label>
        <div style={{ display: 'flex', gap: 4 }}>
          {allowCamera && (
            <CameraTrigger onFiles={handleFiles} capture={obj.capture || 'environment'} />
          )}
          {allowUpload && (
            <UploadTrigger
              label="Add Photos"
              accept="image/*,video/*"
              multiple={true}
              onFiles={handleFiles}
            />
          )}
        </div>
      </div>

      {error && <div style={SU.errorMsg}>{error}</div>}
      {uploads.map(u => <ProgressBar key={u.id} pct={u.pct} filename={u.name} />)}

      {files.length === 0 && uploads.length === 0 && (
        <div style={SU.galleryEmpty}>No photos yet — click Upload to add</div>
      )}

      <div style={{ ...SU.galleryGrid, maxHeight: gridMaxH ? gridMaxH+'px' : undefined, overflowY: gridMaxH ? 'auto' : 'visible' }}>
        {files.map((f, i) => {
          const isPending = thumbPending[f.ID];
          const hasFailed = thumbErrors[f.ID];
          return (
            <div key={f.ID} style={{ ...SU.thumbWrap, width: thumbSize, height: thumbSize }} onClick={() => setLightbox(i)}>
              {isPending ? (
                // Placeholder while Sharp is processing — pulsing grey box
                <div style={{
                  width: '100%', height: '100%',
                  background: 'linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%)',
                  backgroundSize: '200% 100%',
                  animation: 'do-thumb-pulse 1.5s ease-in-out infinite',
                  display: 'flex', alignItems: 'center', justifyContent: 'center',
                }}>
                  <style>{`@keyframes do-thumb-pulse { 0%{background-position:200% 0} 100%{background-position:-200% 0} }`}</style>
                  <span style={{ fontSize: 18, opacity: 0.4 }}>🖼</span>
                </div>
              ) : (
                <img
                  src={hasFailed ? s3Url(bucket, f.KEY) : thumbUrl(bucket, f.KEY, f.EXT)}
                  alt={f.NAME}
                  style={SU.thumb}
                  onError={e => {
                    if (e.target.dataset.errored) return;
                    e.target.dataset.errored = '1';
                    setThumbErrors(prev => ({ ...prev, [f.ID]: true }));
                    const orig = s3Url(bucket, f.KEY);
                    if (e.target.src !== orig) e.target.src = orig;
                    else e.target.style.display = 'none';
                  }}
                />
              )}
              {allowDelete && (
                <button
                  style={SU.thumbDelete}
                  onClick={ev => handleDelete(f, ev)}
                  title="Delete"
                >✕</button>
              )}
            </div>
          );
        })}
      </div>

      {lb && (
        <LightBox
          files={files}
          index={lightbox}
          bucket={bucket}
          allowDelete={allowDelete}
          onNavigate={setLightbox}
          onClose={() => setLightbox(null)}
          onDelete={(f, e) => handleDelete(f, e)}
          thumbErrors={thumbErrors}
          setThumbErrors={setThumbErrors}
        />
      )}
    </div>
  );
}

// ─────────────────────────────────────────────
// FILE LIST
// ─────────────────────────────────────────────
function FileField({ obj, value, appId, table, recordId, bucket: bucketProp, onSaved }) {
  const parsed       = Array.isArray(value) ? value : (value ? tryParse(value) : []);
  const [localFiles, setLocalFiles] = useState(parsed);
  const [uploads,    setUploads]    = useState([]);
  const [error,      setError]      = useState(null);
  const [sortCol,    setSortCol]    = useState('NAME');
  const [sortDir,    setSortDir]    = useState('asc');

  React.useEffect(() => {
    setLocalFiles(Array.isArray(value) ? value : (value ? tryParse(value) : []));
  }, [value]);

  const bucket        = bucketProp || 'https://east.dataobjects.com';
  const allowUpload   = obj.allowUpload   !== false;
  const allowCamera   = obj.allowCamera === true || obj.allowCamera === 'true';
  const allowDownload = obj.allowDownload !== false;
  const allowDelete   = obj.allowDelete   !== false;
  const showSize      = obj.showSize      !== false;
  const showDate      = obj.showDate      !== false;
  const showUser      = obj.showUser      !== false;
  const displayRows   = parseInt(obj.displayRows) || 0;

  const sorted = [...localFiles].sort((a, b) => {
    let av = a[sortCol] || '', bv = b[sortCol] || '';
    if (sortCol === 'SIZE') { av = parseInt(av)||0; bv = parseInt(bv)||0; }
    if (av < bv) return sortDir === 'asc' ? -1 : 1;
    if (av > bv) return sortDir === 'asc' ?  1 : -1;
    return 0;
  });

  function toggleSort(col) {
    if (sortCol === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
    else { setSortCol(col); setSortDir('asc'); }
  }

  function sortArrow(col) {
    if (sortCol !== col) return React.createElement('span', { style:{ color:'#cbd5e1', marginLeft:3 } }, '↕');
    return React.createElement('span', { style:{ color:'#2563eb', marginLeft:3 } }, sortDir === 'asc' ? '↑' : '↓');
  }

  const handleFiles = async (selected) => {
    setError(null);
    for (const file of selected) {
      const id = Date.now() + Math.random();
      setUploads(u => [...u, { id, name: file.name, pct: 0 }]);
      try {
        await uploadFile(file, {
          appId, table, recordId,
          field: obj.field, fieldType: 'file',
          onProgress: pct => setUploads(u => u.map(x => x.id === id ? { ...x, pct } : x)),
        });
        setUploads(u => u.filter(x => x.id !== id));
        onSaved && onSaved(obj.field);
      } catch(err) {
        setError(err.message);
        setUploads(u => u.filter(x => x.id !== id));
      }
    }
  };

  const handleDelete = async (file) => {
    if (!window.confirm(`Delete ${file.NAME}.${file.EXT}?`)) return;
    try {
      const res  = await apiFetch(
        `${API_BASE}/v6/upload/file?app=${appId}&table=${table}&record=${recordId}&field=${obj.field}&fileId=${file.ID}`,
        { method: 'DELETE' }
      );
      const data = await res.json();
      if (data.status !== 'ok') throw new Error(data.message);
      onSaved && onSaved(obj.field);
    } catch(err) { setError(err.message); }
  };

  const ROW_H     = 34;
  const HEAD_H    = 32;
  const tableMaxH = displayRows ? (displayRows * ROW_H + HEAD_H) : undefined;

  const thStyle = { padding:'6px 10px', textAlign:'left', fontSize:11, fontWeight:700,
    color:'#64748b', textTransform:'uppercase', letterSpacing:'0.05em',
    cursor:'pointer', userSelect:'none', whiteSpace:'nowrap',
    background:'#f8fafc', borderBottom:'1px solid #e2e8f0' };
  const tdStyle = { padding:'6px 10px', fontSize:13, color:'#374151',
    borderBottom:'1px solid #f1f5f9', verticalAlign:'middle' };

  return (
    <div style={SU.fieldWrap}>
      <div style={SU.labelRow}>
        <label style={SU.label}>{obj.label || 'Files'}</label>
        <div style={{ display: 'flex', gap: 4 }}>
          {allowCamera && (
            <CameraTrigger onFiles={handleFiles} capture={obj.capture || 'environment'} />
          )}
          {allowUpload && (
            <UploadTrigger label="Upload" accept="*/*" multiple={true} onFiles={handleFiles} />
          )}
        </div>
      </div>
      {error && <div style={SU.errorMsg}>{error}</div>}
      {uploads.map(u => <ProgressBar key={u.id} pct={u.pct} filename={u.name} />)}
      {sorted.length === 0 && uploads.length === 0 && (
        <div style={SU.fileEmpty}>No files attached</div>
      )}
      {sorted.length > 0 && (
        <div style={{ border:'1px solid #e2e8f0', borderRadius:7, overflow:'hidden' }}>
          <div style={{ overflowY: tableMaxH ? 'auto' : 'visible', maxHeight: tableMaxH }}>
            <table style={{ width:'100%', borderCollapse:'collapse', tableLayout:'fixed' }}>
              <thead>
                <tr>
                  <th style={{ ...thStyle, width:28 }}></th>
                  <th style={thStyle} onClick={() => toggleSort('NAME')}>Name {sortArrow('NAME')}</th>
                  {showSize && <th style={{ ...thStyle, width:72 }} onClick={() => toggleSort('SIZE')}>Size {sortArrow('SIZE')}</th>}
                  {showDate && <th style={{ ...thStyle, width:90 }} onClick={() => toggleSort('DATE')}>Date {sortArrow('DATE')}</th>}
                  {showUser && <th style={{ ...thStyle, width:80 }} onClick={() => toggleSort('USER')}>User {sortArrow('USER')}</th>}
                  <th style={{ ...thStyle, width:(allowDownload && allowDelete) ? 56 : 28 }}></th>
                </tr>
              </thead>
              <tbody>
                {sorted.map(f => {
                  const url     = s3Url(bucket, f.KEY);
                  const preview = canPreview(f.EXT);
                  return (
                    <tr key={f.ID} style={{ background:'#fff' }}
                      onMouseEnter={e => e.currentTarget.style.background='#f8fafc'}
                      onMouseLeave={e => e.currentTarget.style.background='#fff'}>
                      <td style={{ ...tdStyle, textAlign:'center', fontSize:16, padding:'4px 6px' }}>{fileIcon(f.EXT)}</td>
                      <td style={{ ...tdStyle, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
                        <span style={{ fontSize:13, color:'#1e293b', fontWeight:500 }}>{f.NAME}.{f.EXT}</span>
                      </td>
                      {showSize && <td style={{ ...tdStyle, fontSize:12, color:'#64748b' }}>{formatSize(f.SIZE)}</td>}
                      {showDate && <td style={{ ...tdStyle, fontSize:12, color:'#64748b' }}>{f.DATE}</td>}
                      {showUser && <td style={{ ...tdStyle, fontSize:12, color:'#64748b', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{f.USER || '—'}</td>}
                      <td style={{ ...tdStyle, padding:'4px 6px', textAlign:'center', whiteSpace:'nowrap' }}>
                        {allowDownload && (
                          <a href={url}
                            target={preview ? '_blank' : undefined}
                            download={!preview ? `${f.NAME}.${f.EXT}` : undefined}
                            rel="noopener noreferrer"
                            title={preview ? 'Preview' : 'Download'}
                            style={{ display:'inline-flex', alignItems:'center', justifyContent:'center',
                              width:24, height:24, borderRadius:4, background:'#f1f5f9',
                              color:'#374151', textDecoration:'none', fontSize:13, marginRight:2 }}>
                            {preview ? '👁' : '⬇'}
                          </a>
                        )}
                        {allowDelete && (
                          <button onClick={() => handleDelete(f)} title="Delete"
                            style={{ display:'inline-flex', alignItems:'center', justifyContent:'center',
                              width:24, height:24, borderRadius:4, background:'#fff0f0',
                              border:'none', color:'#dc2626', cursor:'pointer', fontSize:13 }}>✕</button>
                        )}
                      </td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          </div>
          {displayRows > 0 && sorted.length > displayRows && (
            <div style={{ padding:'5px 10px', fontSize:11, color:'#94a3b8', background:'#f8fafc', borderTop:'1px solid #e2e8f0' }}>
              {sorted.length} files · scroll to see all
            </div>
          )}
        </div>
      )}
    </div>
  );
}

// ─────────────────────────────────────────────
// CAMERA FIELD
// ─────────────────────────────────────────────
function CameraField({ obj, value, appId, table, recordId, bucket: bucketProp, onSaved }) {
  const inputRef    = React.useRef(null);
  const [uploading, setUploading] = useState(false);
  const [progress,  setProgress]  = useState(0);
  const [error,     setError]     = useState(null);
  const [thumbErrors, setThumbErrors] = useState({});

  const bucket      = bucketProp || 'https://east.dataobjects.com';
  const capture     = obj.capture     ?? 'environment';
  const btnLabel    = obj.buttonLabel || 'Take Photo';
  const btnStyle2   = obj.buttonStyle || 'icon-label';
  const showPreview = obj.preview     !== 'no';
  const allowDelete = obj.allowDelete !== 'no';

  const parsed = React.useMemo(() => {
    if (!value) return [];
    try { const v = typeof value === 'string' ? JSON.parse(value) : value; return Array.isArray(v) ? v : []; }
    catch(e) { return []; }
  }, [value]);

  const latest = parsed[parsed.length - 1] || null;

  async function handleCapture(e) {
    const file = e.target.files?.[0];
    if (!file) return;
    e.target.value = '';
    setError(null);
    setUploading(true);
    setProgress(0);
    try {
      await uploadFile(file, {
        appId, table, recordId,
        field:     obj.field,
        fieldType: 'image',
        onProgress: pct => setProgress(pct),
      });
      setUploading(false);
      onSaved && onSaved(obj.field);
    } catch(err) {
      setError(err.message);
      setUploading(false);
    }
  }

  const handleDelete = async (file) => {
    if (!window.confirm('Delete this photo?')) return;
    try {
      const res  = await apiFetch(
        `${API_BASE}/v6/upload/file?app=${appId}&table=${table}&record=${recordId}&field=${obj.field}&fileId=${file.ID}`,
        { method: 'DELETE' }
      );
      const data = await res.json();
      if (data.status !== 'ok') throw new Error(data.message);
      onSaved && onSaved(obj.field);
    } catch(err) { setError(err.message); }
  };

  function triggerCamera() { inputRef.current?.click(); }

  function btnContent() {
    const label = uploading ? `Uploading… ${progress}%` : btnLabel;
    if (btnStyle2 === 'icon')  return <IconCamera size={18} />;
    if (btnStyle2 === 'label') return label;
    return <><IconCamera size={18} /> {label}</>;
  }

  return (
    <div style={SU.fieldWrap}>
      {obj.label && obj.labelpos !== 'none' && (
        <div style={{ fontSize:11, fontWeight:700, color:'#64748b',
          textTransform:'uppercase', letterSpacing:'0.05em', marginBottom:6 }}>
          {obj.label}
        </div>
      )}
      <input
        ref={inputRef}
        type="file"
        accept="image/*"
        capture={capture || undefined}
        style={{ display:'none' }}
        onChange={handleCapture}
      />
      <button
        onClick={triggerCamera}
        disabled={uploading}
        style={{
          display:'inline-flex', alignItems:'center', gap:8,
          padding:'10px 20px', borderRadius:8, cursor: uploading ? 'default' : 'pointer',
          background: uploading ? '#94a3b8' : '#0891b2',
          color:'#fff', border:'none', fontSize:15, fontWeight:600,
          fontFamily:'DM Sans, sans-serif', transition:'background 0.15s',
          boxShadow:'0 2px 8px rgba(8,145,178,0.25)',
        }}>
        {btnContent()}
      </button>
      {uploading && (
        <div style={{ marginTop:8, height:4, background:'#e2e8f0', borderRadius:2, overflow:'hidden' }}>
          <div style={{ height:'100%', width:`${progress}%`, background:'#0891b2',
            transition:'width 0.2s', borderRadius:2 }} />
        </div>
      )}
      {error && <div style={{ ...SU.errorMsg, marginTop:8 }}>{error}</div>}
      {showPreview && latest && !uploading && (
        <div style={{ marginTop:10, position:'relative', display:'inline-block',
          borderRadius:8, overflow:'hidden', border:'1px solid #e2e8f0',
          boxShadow:'0 2px 8px rgba(0,0,0,0.08)' }}>
          <img
            src={thumbErrors[latest.ID] ? s3Url(bucket, latest.KEY) : medUrl(bucket, latest.KEY, latest.EXT)}
            alt={latest.NAME}
            style={{ display:'block', maxWidth:240, maxHeight:180, objectFit:'cover' }}
            onError={e => {
              if (e.target.dataset.errored) return;
              e.target.dataset.errored = '1';
              const orig = s3Url(bucket, latest.KEY);
              if (e.target.src !== orig) e.target.src = orig;
            }}
          />
          {parsed.length > 1 && (
            <div style={{ position:'absolute', top:6, left:6, background:'rgba(0,0,0,0.55)',
              color:'#fff', fontSize:11, padding:'2px 7px', borderRadius:10,
              fontFamily:'DM Mono, monospace' }}>
              {parsed.length} photos
            </div>
          )}
          {allowDelete && (
            <button onClick={() => handleDelete(latest)}
              style={{ position:'absolute', top:6, right:6, width:26, height:26,
                borderRadius:'50%', background:'rgba(220,38,38,0.75)',
                border:'none', color:'#fff', cursor:'pointer', fontSize:13,
                display:'flex', alignItems:'center', justifyContent:'center' }}>✕</button>
          )}
        </div>
      )}
      {showPreview && parsed.length === 0 && !uploading && (
        <div style={{ marginTop:8, fontSize:12, color:'#94a3b8' }}>No photo yet</div>
      )}
    </div>
  );
}

// ─────────────────────────────────────────────
// HELPER
// ─────────────────────────────────────────────
function tryParse(val) {
  try { const p = JSON.parse(val); return Array.isArray(p) ? p : []; }
  catch(e) { return []; }
}

// ─────────────────────────────────────────────
// STYLES
// ─────────────────────────────────────────────
const SU = {
  fieldWrap:       { marginBottom: 16 },
  labelRow:        { display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom:6 },
  label:           { fontSize:12, fontWeight:700, textTransform:'uppercase', letterSpacing:'0.06em', color:'#64748b' },
  uploadBtn:       { background:'#fff', color:'#374151', border:'1.5px solid #cbd5e1', borderRadius:5, padding:'4px 10px', fontSize:12, cursor:'pointer', fontFamily:'inherit', display:'flex', alignItems:'center', gap:4 },
  errorMsg:        { color:'#991b1b', fontSize:12, background:'#fee2e2', padding:'6px 10px', borderRadius:4, marginBottom:6 },
  progressWrap:    { marginBottom:6 },
  progressLabel:   { fontSize:11, color:'#64748b', marginBottom:2 },
  progressTrack:   { height:4, background:'#e2e8f0', borderRadius:2, overflow:'hidden' },
  progressFill:    { height:'100%', background:'#2563eb', borderRadius:2, transition:'width 0.2s' },
  imageWrap:       { border:'1px solid #e2e8f0', borderRadius:8, overflow:'hidden', position:'relative' },
  image:           { width:'100%', display:'block', objectFit:'cover' },
  imagePlaceholder:{ border:'2px dashed #e2e8f0', borderRadius:8, padding:'32px', textAlign:'center', color:'#94a3b8', fontSize:13 },
  galleryEmpty:    { color:'#94a3b8', fontSize:13, padding:'16px 0' },
  galleryGrid:     { display:'flex', flexWrap:'wrap', gap:8, marginTop:4 },
  thumbWrap:       { position:'relative', cursor:'pointer', borderRadius:6, overflow:'hidden', width:100, height:100, background:'#f1f5f9', flexShrink:0 },
  thumb:           { width:'100%', height:'100%', objectFit:'cover', display:'block' },
  thumbDelete:     { position:'absolute', top:3, right:3, background:'rgba(0,0,0,0.6)', color:'#fff', border:'none', borderRadius:'50%', width:20, height:20, fontSize:11, cursor:'pointer', display:'flex', alignItems:'center', justifyContent:'center', lineHeight:1 },
  fileEmpty:       { color:'#94a3b8', fontSize:13, padding:'8px 0' },
  fileList:        { display:'flex', flexDirection:'column', gap:6, marginTop:4 },
  fileRow:         { display:'flex', alignItems:'center', gap:10, padding:'8px 10px', background:'#f8fafc', border:'1px solid #e2e8f0', borderRadius:6 },
  fileIcon:        { fontSize:20, flexShrink:0 },
  fileInfo:        { flex:1, minWidth:0, display:'flex', flexDirection:'column', gap:2 },
  fileName:        { fontSize:13, fontWeight:600, color:'#1a3a5c', textDecoration:'none', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' },
  fileMeta:        { fontSize:11, color:'#94a3b8' },
  deleteBtn:       { background:'none', border:'none', color:'#94a3b8', cursor:'pointer', fontSize:14, padding:'2px 4px', flexShrink:0 },
  downloadBtn:     { color:'#64748b', fontSize:13, textDecoration:'none', padding:'2px 4px', flexShrink:0 },
};
