/* Drag to reorder — the playlist page's rows and the player sheet's queue,
   one gesture for both. A phone should drag better than a desktop does:
   the desktop has a grip and a mouse (src/views/Playlist.svelte), the phone
   has a finger that is also how the list is scrolled, so most of this file
   is about telling those two apart and then getting out of the scroller's
   way.

   The contract, and it is strictly additive:

     hold 320ms          the row lifts (and the phone taps back)
     move                the rows below make room; let go and it lands
     release, unmoved    the row's sheet opens, exactly as it always did

   That last line is load-bearing. A queue row has no ⋯ — `oncontextmenu`
   is its only door to Play now, Move to next and Remove from queue — so a
   long press that merely started a drag would strand that sheet.

   A Svelte action on the CONTAINER, in util.js's `drag` idiom: armed on
   pointerdown, live only once the hold has landed, every pointer capture
   in a try/catch (a synthetic pointer id is not capturable, and that must
   not be fatal — it is what the walk drives this with).

     use:reorder={{ enabled, rowSel, skipSel, scrollSel,
                    onLift(from), onDrop(from, to), onHold(index), onEnd(dropped) }}

   `enabled` may be a value or a FUNCTION, and the playlist passes a
   function on purpose: an action's `update` is deprecated in Svelte 5, so
   an option object that has to change identity to be heard is one this
   cannot lean on. Asked at the press instead, the answer is always the
   current one.
*/

/** ms of stillness before a press becomes a lift: past a tap, and short of
 *  Android's own 500ms contextmenu, so the gesture is ours by then */
const HOLD = 320;
/** px of movement before that: the list is being scrolled, let go of it */
const SLOP = 8;
/** px from the scroller's edge that pulls the list along */
const EDGE = 64;
/** px per frame at the very edge */
const MAXSTEP = 14;

export function reorder(el, opts) {
    let o = opts;
    let armed = null;   /* { id, x, y, row } — pressed, the hold not yet landed */
    let live = null;    /* the lift: the rows, their rects, the slot we are over */
    let holdTimer = 0;
    /* a released drag would otherwise fire the row's own click and play the
       track, and Android's long press would open the row's sheet in the
       middle of one. Both are swallowed at the container in the CAPTURE
       phase — which beats the row's handler and Svelte's delegated root
       listener alike — and only while a lift is live. */
    let suppressUntil = 0;

    const contentY = (clientY) => (live.scroller ? clientY - live.box.top + live.scroller.scrollTop : clientY);
    /** asked at the press, at the lift and at the drop, never cached */
    const ok = () => { const v = o && o.enabled; return typeof v === "function" ? !!v() : !!v; };

    function attach() {
        /* on the window, not the row: the gesture has to survive a capture
           that never happened */
        window.addEventListener("pointermove", move);
        window.addEventListener("pointerup", finish);
        window.addEventListener("pointercancel", finish);
    }
    function detach() {
        window.removeEventListener("pointermove", move);
        window.removeEventListener("pointerup", finish);
        window.removeEventListener("pointercancel", finish);
    }
    /* `touch-action` is latched at touchstart and cannot be flipped
       mid-gesture, and touch-action: none on a row would cost the list the
       flick that scrolls it — most of the page. So the scroll is refused
       here instead: after 320ms of stillness the browser has not started
       one yet, and eating the first touchmove after the lift cancels it.
       Registered imperatively because it must be NON-passive; Svelte
       registers touch handlers passively, and a passive listener may not
       preventDefault. */
    const eatTouch = (e) => { if (live && e.cancelable) e.preventDefault(); };
    const guard = (e) => {
        if (Date.now() > suppressUntil) return;
        e.preventDefault();
        e.stopPropagation();
    };

    function down(e) {
        if (live || armed || !ok()) return;
        if (e.button > 0) return;
        const t = e.target;
        if (!t || !t.closest) return;
        if (o.skipSel && t.closest(o.skipSel)) return;   /* the row's own ⋯ */
        const row = t.closest(o.rowSel);
        if (!row || !el.contains(row)) return;
        armed = { id: e.pointerId, x: e.clientX, y: e.clientY, row };
        clearTimeout(holdTimer);
        holdTimer = setTimeout(lift, HOLD);
        attach();
    }

    function lift() {
        holdTimer = 0;
        if (!armed || live || !ok()) { if (armed) { armed = null; detach(); } return; }
        const a = armed;
        const rows = [].slice.call(el.querySelectorAll(o.rowSel));
        const from = rows.indexOf(a.row);
        if (from < 0 || rows.length < 2) { armed = null; detach(); return; }
        const scroller = o.scrollSel ? el.closest(o.scrollSel) : null;
        const box = scroller ? scroller.getBoundingClientRect() : { top: 0 };
        const top0 = scroller ? scroller.scrollTop : 0;
        /* every row measured once, in the scroller's CONTENT space. That is
           what makes the edge pull work: as scrollTop grows the snapshot
           stays true and the delta grows on its own, so the row keeps up
           with the finger. Measured rather than one assumed row height —
           the desktop's `rowH` — because the queue face puts an album
           separator between rows. */
        const snap = rows.map((r) => {
            const b = r.getBoundingClientRect();
            const top = b.top - box.top + top0;
            return { el: r, top, mid: top + b.height / 2 };
        });
        live = {
            id: a.id, row: a.row, from, to: from, snap, scroller, box,
            grabY: a.y - box.top + top0, y: a.y, y0: a.y, x0: a.x,
            moved: false, raf: 0, shift: snap.map(() => 0),
        };
        armed = null;
        el.classList.add("dragging");
        a.row.classList.add("lifted");
        try { a.row.setPointerCapture(a.id); } catch { /* synthetic ids are not capturable */ }
        window.addEventListener("touchmove", eatTouch, { passive: false });
        el.addEventListener("click", guard, true);
        el.addEventListener("contextmenu", guard, true);
        suppressUntil = Date.now() + 400;
        try { if (navigator.vibrate) navigator.vibrate(8); } catch { /* no motor, or none allowed */ }
        if (o.onLift) o.onLift(from);
        paint();
    }

    function paint() {
        if (!live) return;
        live.raf = 0;
        const s = live.scroller;
        if (s) {
            const b = s.getBoundingClientRect();
            live.box = b;
            /* the edges pull: near the top or the bottom of the scroller the
               list comes to the finger, rather than the finger having to
               leave the screen to reach the end of a long playlist */
            const above = live.y - b.top;
            const below = b.bottom - live.y;
            let step = 0;
            if (above < EDGE) step = -Math.ceil(((EDGE - Math.max(0, above)) / EDGE) * MAXSTEP);
            else if (below < EDGE) step = Math.ceil(((EDGE - Math.max(0, below)) / EDGE) * MAXSTEP);
            if (step) {
                const was = s.scrollTop;
                s.scrollTop = Math.max(0, Math.min(s.scrollHeight - s.clientHeight, was + step));
                /* the list actually moved: keep the frames coming even while
                   the finger is still */
                if (s.scrollTop !== was) live.raf = requestAnimationFrame(paint);
            }
        }
        const dy = contentY(live.y) - live.grabY;
        live.row.style.transform = "translateY(" + Math.round(dy) + "px)";
        /* where the lifted row's own middle now sits decides the slot */
        const mid = live.snap[live.from].mid + dy;
        let to = live.from;
        for (let i = 0; i < live.snap.length; i++) {
            if (i === live.from) continue;
            if (i < live.from && mid < live.snap[i].mid) to = Math.min(to, i);
            else if (i > live.from && mid > live.snap[i].mid) to = Math.max(to, i);
        }
        /* each displaced row takes its neighbour's slot — the exact
           difference of their measured tops, not a row height. Only the
           rows whose shift CHANGED are written to, so a frame that moves
           nothing costs nothing. */
        for (let i = 0; i < live.snap.length; i++) {
            if (i === live.from) continue;
            let sh = 0;
            if (to > live.from && i > live.from && i <= to) sh = live.snap[i - 1].top - live.snap[i].top;
            else if (to < live.from && i >= to && i < live.from) sh = live.snap[i + 1].top - live.snap[i].top;
            if (sh !== live.shift[i]) {
                live.shift[i] = sh;
                live.snap[i].el.style.transform = sh ? "translateY(" + Math.round(sh) + "px)" : "";
            }
        }
        live.to = to;
    }

    function move(e) {
        if (live) {
            if (e.pointerId !== live.id) return;
            live.y = e.clientY;
            /* the same slop the arming uses, not a hair's breadth: a finger
               held still for 320ms still wanders a few px, and a press
               "released without moving" has to open the row's sheet the way
               it always did — on the queue there is no other door to it. */
            if (Math.abs(e.clientY - live.y0) > SLOP || Math.abs(e.clientX - live.x0) > SLOP) live.moved = true;
            suppressUntil = Date.now() + 400;
            if (e.cancelable) e.preventDefault();
            /* one frame, however many moves arrive in it */
            if (!live.raf) live.raf = requestAnimationFrame(paint);
            return;
        }
        if (!armed || e.pointerId !== armed.id) return;
        if (Math.abs(e.clientY - armed.y) > SLOP || Math.abs(e.clientX - armed.x) > SLOP) {
            /* it moved before the hold landed: that was a scroll */
            clearTimeout(holdTimer);
            holdTimer = 0;
            armed = null;
            detach();
        }
    }

    function finish(e) {
        if (e && live && e.pointerId !== live.id) return;
        if (e && !live && armed && e.pointerId !== armed.id) return;
        clearTimeout(holdTimer);
        holdTimer = 0;
        detach();
        armed = null;
        const was = live;
        live = null;
        if (!was) return;
        if (was.raf) cancelAnimationFrame(was.raf);
        window.removeEventListener("touchmove", eatTouch);
        /* every mark cleared BEFORE the mutation. The playlist is keyed by
           id so a drop MOVES nodes; the queue is keyed by index so it
           re-fills them — either way an inline transform left on one is a
           bug you can see. */
        el.classList.remove("dragging");
        was.row.classList.remove("lifted");
        was.row.style.transform = "";
        for (let i = 0; i < was.snap.length; i++) was.snap[i].el.style.transform = "";
        try { was.row.releasePointerCapture(was.id); } catch { /* never captured */ }
        suppressUntil = Date.now() + 350;
        setTimeout(unguard, 420);
        /* asked once more here: a filter typed with a finger still down
           would make the landing index a lie about the saved order */
        /* a cancelled pointer is not a drop: Android takes the touch away
           when it decides the gesture is its own, and landing the row then
           would be a reorder nobody asked for. Unmoved it is still the long
           press it looks like, so the sheet opens either way. */
        const cancelled = !!e && e.type === "pointercancel";
        const dropped = !cancelled && was.to !== was.from && ok();
        if (dropped) { if (o.onDrop) o.onDrop(was.from, was.to); }
        else if (!was.moved) { if (o.onHold) o.onHold(was.from); }
        if (o.onEnd) o.onEnd(dropped);
    }
    function unguard() {
        if (live) return;   /* another lift has taken the guards over */
        el.removeEventListener("click", guard, true);
        el.removeEventListener("contextmenu", guard, true);
    }

    el.addEventListener("pointerdown", down);
    return {
        /* deprecated in Svelte 5 and not relied on — `enabled` is asked for
           through a function — but honoured if it ever does arrive */
        update(next) { o = next; },
        destroy() {
            clearTimeout(holdTimer);
            /* torn down with a row still in hand — a navigation that happened
               under the finger. onEnd is what releases the page's deferred
               re-read, so it has to be told, or the playlist would stop
               re-reading its rows for the rest of the session. */
            if (live) {
                if (live.raf) cancelAnimationFrame(live.raf);
                live = null;
                if (o && o.onEnd) { try { o.onEnd(false); } catch { /* the page is leaving anyway */ } }
            }
            armed = null;
            detach();
            window.removeEventListener("touchmove", eatTouch);
            el.removeEventListener("pointerdown", down);
            el.removeEventListener("click", guard, true);
            el.removeEventListener("contextmenu", guard, true);
        },
    };
}
