// PhotoGallery — an entity's images as an ORDERED gallery (migration 0056):
// the first photo is the cover, drag to reorder, click a label to caption.
// Industry shape (MLS/Zillow) chosen 2026-08-20 over fixed slots ("Bedroom 1",
// "Bathroom 1"…) and over filename conventions — see the migration header.
//
// Core component, not pack-only: ordering/captions are generic attachment
// features; the realtor property modal is simply the first host. Hosts that
// mount this should mount AttachmentsPanel with exclude="images" beside it
// so a photo never appears in two lists.
//
// Images load through api.attachmentImageUrl (fetch + object URL) because an
// <img src> can't carry the bearer header. Object URLs are revoked on unmount.
//
// Every button is type="button" ON PURPOSE — this renders inside host <form>s
// (the property modal), where a bare <button> would submit the form.
// Phone photos are 3–6 MB and 4000px wide; a menu card needs ~400px. Photos
// get DOWNSCALED in the browser before upload (contain-fit within FIT_PX,
// JPEG at QUALITY) — the Logo Studio's canvas trick, no server image library
// (a native binary on small droplets is the wrong dependency, 2026-08-25).
// Anything already small passes through untouched; GIFs keep their frames
// by skipping the canvas entirely. Server caps (25 MB, storage limit) stay
// the hard backstop; this is the fast path, not the gate.
const FIT_PX  = 1600;
const QUALITY = 0.85;
const PASS_THROUGH_BYTES = 400 * 1024;
async function downscaleImage(file) {
    if (file.type === 'image/gif' || file.type === 'image/svg+xml') return file;
    let bitmap;
    try { bitmap = await createImageBitmap(file); } catch { return file; }   // undecodable → let the server decide
    const { width: w, height: h } = bitmap;
    if (w <= FIT_PX && h <= FIT_PX && file.size <= PASS_THROUGH_BYTES) { bitmap.close?.(); return file; }
    const scale  = Math.min(1, FIT_PX / Math.max(w, h));
    const canvas = document.createElement('canvas');
    canvas.width = Math.round(w * scale); canvas.height = Math.round(h * scale);
    canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
    bitmap.close?.();
    const blob = await new Promise(res => canvas.toBlob(res, 'image/jpeg', QUALITY));
    if (!blob || blob.size >= file.size) return file;   // re-encoding didn't help — keep the original
    const name = file.name.replace(/\.[^.]+$/, '') + '.jpg';
    return new File([blob], name, { type: 'image/jpeg' });
}

const PhotoGallery = ({ entityType, entityId, title = 'Photos' }) => {
    const [photos, setPhotos]       = React.useState([]);
    const [loading, setLoading]     = React.useState(true);
    const [uploading, setUploading] = React.useState(false);
    const [dragOver, setDragOver]   = React.useState(false);   // file drop zone
    const [error, setError]         = React.useState(null);
    const [urls, setUrls]           = React.useState({});      // id → object URL
    // Tile drag state (HTML5 DnD, no library): which tile is lifted, and where
    // it would land (index + before/after) so the drop marker can render.
    const [dragId, setDragId]       = React.useState(null);
    const [dropAt, setDropAt]       = React.useState(null);    // { id, side }
    // Caption being edited: a text input inside a draggable tile can't select
    // text (the browser starts a drag instead), so the tile stops being
    // draggable while its caption has focus.
    const [editing, setEditing]     = React.useState(null);
    const fileInputRef = React.useRef(null);
    const urlsRef      = React.useRef({});

    const isImage = (a) => (a.mime_type || '').startsWith('image/');

    const load = React.useCallback(() => {
        return api.getAttachments(entityType, entityId)
            .then(list => setPhotos(list.filter(isImage)))
            .catch(err => setError('Could not load photos: ' + err.message))
            .finally(() => setLoading(false));
    }, [entityType, entityId]);

    React.useEffect(() => { setLoading(true); load(); }, [load]);

    // Fetch thumbnails for any photo we don't have a URL for yet. Sequential
    // on purpose: a 60-photo listing shouldn't open 60 parallel requests.
    React.useEffect(() => {
        let cancelled = false;
        (async () => {
            for (const p of photos) {
                if (cancelled) return;
                if (urlsRef.current[p.id]) continue;
                try {
                    const u = await api.attachmentImageUrl(p.id);
                    if (cancelled) { URL.revokeObjectURL(u); return; }
                    urlsRef.current = { ...urlsRef.current, [p.id]: u };
                    setUrls(urlsRef.current);
                } catch { /* tile shows a placeholder; the list still works */ }
            }
        })();
        return () => { cancelled = true; };
    }, [photos]);

    // Release every object URL when the gallery unmounts (memory, not correctness).
    React.useEffect(() => () => {
        Object.values(urlsRef.current).forEach(u => URL.revokeObjectURL(u));
    }, []);

    const handleFiles = async (files) => {
        if (!files?.length) return;
        setUploading(true); setError(null);
        try {
            for (const file of Array.from(files)) {
                if (!(file.type || '').startsWith('image/')) {
                    throw new Error(`"${file.name}" isn't an image — documents go in the Documents list below.`);
                }
                await api.uploadAttachment(entityType, entityId, await downscaleImage(file));
            }
            await load();
        } catch (err) {
            setError('Upload failed: ' + err.message);
        } finally {
            setUploading(false);
        }
    };

    // Persist a new order: optimistic local update, server is the authority —
    // on failure reload so the grid shows what's actually saved.
    const saveOrder = async (next) => {
        setPhotos(next);
        try { await api.reorderAttachments(entityType, entityId, next.map(p => p.id)); }
        catch (err) { setError('Could not save order: ' + err.message); load(); }
    };

    const makeCover = (id) => {
        const idx = photos.findIndex(p => p.id === id);
        if (idx <= 0) return;
        const next = [...photos];
        const [p] = next.splice(idx, 1);
        next.unshift(p);
        saveOrder(next);
    };

    const moveTo = (fromId, toId, side) => {
        if (String(fromId) === String(toId)) return;
        const next = [...photos];
        const from = next.findIndex(p => String(p.id) === String(fromId));
        const [p] = next.splice(from, 1);
        let to = next.findIndex(q => String(q.id) === String(toId));
        if (side === 'after') to += 1;
        next.splice(to, 0, p);
        saveOrder(next);
    };

    const handleDelete = async (p) => {
        if (!await confirmAction({
            title: 'Delete photo',
            message: `"${p.original_name}" will be removed from this record and from disk. This cannot be undone.`,
            confirmLabel: 'Delete photo',
        })) return;
        setError(null);
        try { await api.deleteAttachment(p.id); load(); }
        catch (err) { setError(err.message); }
    };

    // Caption saves on blur / Enter; blank clears. Local echo keeps typing smooth.
    const setCaptionLocal = (id, caption) =>
        setPhotos(ps => ps.map(p => p.id === id ? { ...p, caption } : p));
    const saveCaption = async (p) => {
        const caption = (p.caption || '').trim() || null;
        try { await api.updateAttachment(p.id, { caption }); }
        catch (err) { setError('Could not save label: ' + err.message); load(); }
    };

    // Tile DnD (HTML5, no library). The drop target is computed from POINTER
    // GEOMETRY on the grid, at dragover AND again at drop — never read back
    // from state. v1 listened per tile and cleared the marker on every
    // dragleave, which fires each time the pointer crosses into a tile's
    // child (image, caption box, buttons); let go in that instant, or in the
    // gap between tiles, and nothing happened. Felt like "only one magic spot
    // works" (reported 2026-08-23). Now every pixel of the grid is a valid
    // drop: nearest tile wins, left/right half picks before/after.
    const gridRef   = React.useRef(null);
    const dragIdRef = React.useRef(null);   // ref: drop handlers can't wait on a re-render
    const locateDrop = (e) => {
        const grid = gridRef.current;
        if (!grid) return null;
        let best = null;
        for (const el of grid.querySelectorAll('.photo-tile')) {
            const id = el.dataset.id;
            if (!id || id === String(dragIdRef.current)) continue;
            const r = el.getBoundingClientRect();
            // Distance from pointer to the tile's box (0 when inside it).
            const dx = Math.max(r.left - e.clientX, 0, e.clientX - r.right);
            const dy = Math.max(r.top - e.clientY, 0, e.clientY - r.bottom);
            const d = dx * dx + dy * dy;
            if (!best || d < best.d) best = { d, id, side: e.clientX < r.left + r.width / 2 ? 'before' : 'after' };
        }
        return best && { id: best.id, side: best.side };
    };
    const onTileDragStart = (e, id) => {
        dragIdRef.current = id; setDragId(id);
        e.dataTransfer.effectAllowed = 'move';
        e.dataTransfer.setData('text/plain', String(id));   // Firefox won't start a drag without data
    };
    // Auto-scroll while dragging. Browsers only edge-scroll a scroll container
    // when the pointer sits in a sliver a few px wide (reported 2026-08-23 as
    // "it only scrolls at one precise point"), so we run our own: dragover
    // measures how deep the pointer is in a 70px zone at the grid's top/bottom,
    // stores a velocity, and a rAF loop keeps scrolling even while the pointer
    // is still (dragover fires only ~3×/s when stationary). When the grid has
    // nothing left to scroll, the modal behind it scrolls instead, so the
    // whole photo list is reachable in one drag.
    const scrollVelRef = React.useRef(0);
    const rafRef       = React.useRef(null);
    const EDGE = 70, MAX_STEP = 18;
    const scrollTick = () => {
        const v = scrollVelRef.current, grid = gridRef.current;
        if (!v || !grid) { rafRef.current = null; return; }
        const canGrid = v < 0 ? grid.scrollTop > 0 : grid.scrollTop + grid.clientHeight < grid.scrollHeight - 1;
        const target = canGrid ? grid : grid.closest('.modal');
        if (target) target.scrollTop += v;
        rafRef.current = requestAnimationFrame(scrollTick);
    };
    const updateAutoScroll = (e) => {
        const r = gridRef.current.getBoundingClientRect();
        let v = 0;
        if (e.clientY < r.top + EDGE)         v = -MAX_STEP * (1 - Math.max(0, e.clientY - r.top) / EDGE);
        else if (e.clientY > r.bottom - EDGE)  v =  MAX_STEP * (1 - Math.max(0, r.bottom - e.clientY) / EDGE);
        scrollVelRef.current = v;
        if (v && rafRef.current == null) rafRef.current = requestAnimationFrame(scrollTick);
    };
    const stopAutoScroll = () => { scrollVelRef.current = 0; if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } };
    React.useEffect(() => stopAutoScroll, []);   // unmount mid-drag: no orphan rAF loop

    const onGridDragOver = (e) => {
        if (dragIdRef.current == null) return;             // a FILE drag — the upload zone handles it
        e.preventDefault();                                 // allows the drop anywhere on the grid
        e.dataTransfer.dropEffect = 'move';
        updateAutoScroll(e);
        const at = locateDrop(e);
        setDropAt(d => (d && at && d.id === at.id && d.side === at.side) ? d : at);
    };
    const onGridDragLeave = (e) => {
        // Only clear the marker when leaving the GRID itself, not a child.
        if (!e.currentTarget.contains(e.relatedTarget)) { setDropAt(null); stopAutoScroll(); }
    };
    const onGridDrop = (e) => {
        if (dragIdRef.current == null) return;
        e.preventDefault();
        const at = locateDrop(e);
        if (at) moveTo(dragIdRef.current, at.id, at.side);
        stopAutoScroll(); dragIdRef.current = null; setDragId(null); setDropAt(null);
    };
    const onTileDragEnd = () => { stopAutoScroll(); dragIdRef.current = null; setDragId(null); setDropAt(null); };

    return (
        <div className="attachments-section">
            <h4>
                <span><i className="fas fa-images" style={{ color: 'var(--text-3)' }}></i> {title} {photos.length > 0 && `(${photos.length})`}</span>
                <button type="button" className="btn btn-secondary btn-small"
                        onClick={() => fileInputRef.current?.click()} disabled={uploading}>
                    {uploading ? <><i className="fas fa-spinner fa-spin"></i> Uploading…</> : <><i className="fas fa-upload"></i> Add photos</>}
                </button>
            </h4>
            <input ref={fileInputRef} type="file" multiple accept="image/*" style={{ display: 'none' }}
                   onChange={e => { handleFiles(e.target.files); e.target.value = ''; }} />

            {/* File drop zone — only for FILES from outside; tile drags (which
                set dragId) must not light it up or be swallowed by it. */}
            <div className={`upload-zone ${dragOver ? 'drag-over' : ''}`}
                 style={{ marginBottom: '0.75rem', padding: '0.75rem' }}
                 onClick={() => fileInputRef.current?.click()}
                 onDragOver={(e) => { if (dragId != null) return; e.preventDefault(); setDragOver(true); }}
                 onDragLeave={() => setDragOver(false)}
                 onDrop={(e) => { if (dragId != null) return; e.preventDefault(); setDragOver(false); handleFiles(e.dataTransfer.files); }}>
                <i className="fas fa-cloud-upload-alt" style={{ marginRight: '0.5rem' }}></i>
                Drop photos here or click to browse (25 MB each). Drag tiles to set the order buyers see — the first photo is the cover.
            </div>

            {error && (
                <p style={{ color: 'var(--danger, #ef4444)', fontSize: '0.8125rem', marginBottom: '0.5rem' }}>
                    <i className="fas fa-triangle-exclamation" style={{ marginRight: '0.35rem' }}></i>{error}
                </p>
            )}

            {loading ? (
                <div style={{ color: 'var(--text-3)', fontSize: '0.875rem' }}><i className="fas fa-spinner fa-spin"></i> Loading…</div>
            ) : photos.length === 0 ? (
                <p style={{ color: 'var(--text-3)', fontSize: '0.8125rem' }}>No photos yet.</p>
            ) : (
                <div className="photo-grid" ref={gridRef}
                     style={{ maxHeight: '36rem', overflowY: 'auto', paddingRight: '0.25rem' }}
                     onDragOver={onGridDragOver} onDragLeave={onGridDragLeave} onDrop={onGridDrop}>
                    {photos.map((p, i) => {
                        const cls = ['photo-tile',
                            dragId === p.id ? 'dragging' : '',
                            dropAt && String(dropAt.id) === String(p.id) ? `drop-${dropAt.side}` : ''].join(' ');
                        return (
                            <div key={p.id} className={cls} draggable={editing !== p.id} data-id={p.id}
                                 onDragStart={(e) => onTileDragStart(e, p.id)}
                                 onDragEnd={onTileDragEnd}
                                 title={p.original_name}>
                                {urls[p.id]
                                    ? <img src={urls[p.id]} alt={p.caption || p.original_name} draggable={false} />
                                    : <div className="photo-placeholder"><i className="fas fa-image"></i></div>}
                                {i === 0 && <span className="photo-cover">COVER</span>}
                                <span className="photo-pos">{i + 1}</span>
                                <div className="photo-actions">
                                    {i > 0 && (
                                        <button type="button" title="Make this the cover photo" onClick={() => makeCover(p.id)}>
                                            <i className="fas fa-star"></i>
                                        </button>
                                    )}
                                    <button type="button" title="Delete photo" onClick={() => handleDelete(p)}>
                                        <i className="fas fa-trash"></i>
                                    </button>
                                </div>
                                <input className="photo-caption" type="text" maxLength={200}
                                       placeholder="Add a label (e.g. Primary bedroom)"
                                       value={p.caption || ''}
                                       onChange={e => setCaptionLocal(p.id, e.target.value)}
                                       onFocus={() => setEditing(p.id)}
                                       onBlur={() => { setEditing(null); saveCaption(p); }}
                                       onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
                                       draggable={false} />
                            </div>
                        );
                    })}
                </div>
            )}
        </div>
    );
};
