import { defineTrait } from '../define.js';

/**
 * `draggable-list-item` — pointer-driven list-reorder lifter.
 *
 * Authored for the Tasks UI playground (apps/tasks/spec/).
 * Per SPEC §6.2 + ARCHITECTURE-REVIEW H2 (category: 'motion-positioning').
 *
 * Different mental model than the existing `draggable` trait:
 *
 *   `draggable` (free-positioning):  pointer drag → mutate style.translate;
 *                                    emits `drag-end {x, y}`.
 *   `draggable-list-item` (reorder): pointer drag → lift the item, signal
 *                                    drop targets to render affordances,
 *                                    then on release the parent rewrites
 *                                    the child order. The source DOES NOT
 *                                    physically move during the drag —
 *                                    only `data-draggable-list-item-lifting`
 *                                    is reflected, with optional `opacity:
 *                                    0.4` styling left to component CSS
 *                                    (Atlassian Pragmatic DnD convention,
 *                                    SPEC §6.5).
 *
 * Coordinates with sibling traits via document-scoped CustomEvents:
 *   dispatch  `dnd-lift`                — on pointerdown + threshold cross
 *   dispatch  `dnd-drop-target-change` — when hovered droppable changes
 *   dispatch  `dnd-drop`               — on pointerup over a valid target
 *   dispatch  `dnd-drop-cancel`        — on pointerup off-target / Esc
 *
 * `[droppable]` listens for `dnd-drop-target-change` and self-marks.
 * `[droppable-collection]` listens for `dnd-drop` from any descendant
 * droppable and re-emits `dnd-collection-drop`.
 *
 * Hit-testing is `document.elementFromPoint()` + closest `[data-droppable-id]`
 * walk (no shared registry — DOM is the source of truth).
 *
 * Keyboard alternative is delivered by the sibling `keyboard-reorderable`
 * trait (per WCAG 2.5.7) — both traits consume the shared `announcer-stage.js`
 * aria-live regions so screen-reader narration is single-stream.
 */

import { announce } from '../announcer/announcer-stage.js';

// Drop-target-change announcements are debounced at 200 ms (per
// a11y-live-region-drag-announcements.md research note); other phases
// (lift / drop / cancel) announce immediately and supersede the pending
// debounced message. The shared announcer-stage handles the actual
// aria-live write; we just gate the call here.
const DEBOUNCE_MS = 200;
let debounceHandle = null;
function announceDebounced(message) {
  if (debounceHandle != null) clearTimeout(debounceHandle);
  debounceHandle = setTimeout(() => {
    debounceHandle = null;
    announce(message, 'polite');
  }, DEBOUNCE_MS);
}
function announceImmediate(message) {
  if (debounceHandle != null) {
    clearTimeout(debounceHandle);
    debounceHandle = null;
  }
  announce(message, 'polite');
}

const ATTR_LIFTING = 'data-draggable-list-item-lifting';
const ATTR_SOURCE_ID = 'data-draggable-list-item-id';
const ATTR_GHOST = 'data-draggable-list-item-ghost';
const ATTR_GHOST_ACTIVE = 'data-draggable-list-item-ghost-active';
const DRAG_THRESHOLD_PX = 4;

/** @type {{ container: Element; index: number } | null} */
function readSourceContext(host) {
  const container = host.parentElement;
  if (!container) return null;
  const siblings = Array.from(container.children).filter((c) => c.matches?.('[data-draggable-list-item-id]'));
  const index = siblings.indexOf(host);
  return { container, index };
}

function readContainerId(el) {
  // Walk up looking for the nearest `[data-droppable-id]`.
  /** @type {Element | null} */
  let cur = el;
  while (cur && cur !== document.body) {
    if (cur.hasAttribute?.('data-droppable-id')) return cur.getAttribute('data-droppable-id');
    cur = cur.parentElement;
  }
  return null;
}

function dropTargetFromPoint(x, y) {
  const el = document.elementFromPoint(x, y);
  if (!el) return null;
  /** @type {Element | null} */
  let cur = el;
  while (cur && cur !== document.body) {
    if (cur.hasAttribute?.('data-droppable-id')) return cur;
    cur = cur.parentElement;
  }
  return null;
}

/**
 * Compute the column-relative insertion index at pointer (x, y) within
 * a vertical-list droppable target. The returned index is in
 * POST-REMOVAL coordinates — i.e., it counts items as if the dragging
 * source had already been removed from the list.
 *
 * Why post-removal: the consumer's command (Move/Reorder) splices the
 * source out FIRST, then splices in at `to_position`. If the trait
 * counted the source in pre-removal coordinates, downward intra-list
 * drags would land one slot too far (the source's current slot
 * disappears post-removal, shifting indices down by one). Pre-removal
 * cross-list drags happen to be immune, but the math is identical.
 *
 * Items are filtered to DIRECT CHILDREN with the trait's id attribute —
 * not descendants — to keep nested droppables from poisoning the count.
 * Zero-layout items (display:none, off-flow) are skipped.
 *
 * @param {Element} target — the droppable container ([data-droppable-id])
 * @param {number} _x — pointer client X (unused for vertical-list math; kept for future horizontal-list support)
 * @param {number} y — pointer client Y
 * @param {string | null} sourceId — the dragging source's data-draggable-list-item-id; this item is
 *   skipped while counting so the result is in post-removal coordinates
 * @returns {number} insertion index in [0, count_after_removal]
 */
function indexWithinTarget(target, _x, y, sourceId) {
  // Find all `[data-draggable-list-item-id]` descendants whose nearest
  // `[data-droppable-id]` ancestor IS the target. This supports both
  // structural patterns:
  //   • Items as DIRECT children of the droppable host
  //     (e.g. `<list-section data-droppable-id> <task-row-ui>...`).
  //   • Items nested inside an intermediate container
  //     (e.g. `<task-column-ui data-droppable-id> <div class="col-list">
  //     <task-card-ui>...` — the column's CSS owns chrome on the host
  //     while cards live in a flow-styled inner list).
  //
  // The closest-ancestor filter keeps nested droppables from poisoning
  // the count: a child droppable's items have a different closest
  // `[data-droppable-id]`, so they're skipped here.
  //
  // Pre-filter to source-skipped, layout-having items so we know which
  // is truly last in post-removal coordinates.
  const visible = [];
  const candidates = target.querySelectorAll('[data-draggable-list-item-id]');
  for (const child of candidates) {
    if (child.closest('[data-droppable-id]') !== target) continue;
    if (sourceId && child.getAttribute('data-draggable-list-item-id') === sourceId) continue;
    const r = child.getBoundingClientRect();
    if (r.height === 0 && r.width === 0) continue;
    visible.push(r);
  }
  if (visible.length === 0) return 0;

  for (let i = 0; i < visible.length; i++) {
    const r = visible[i];
    // Standard midline test (Pragmatic-DnD / Linear / Notion convention):
    // cursor above this item's mid-line → insert before it; below → fall
    // through to the next item. After the loop, if no hit, drop after
    // all items (cursor was below every mid-line).
    //
    // History: v0.2.5 introduced a "last-item generous zone" that
    // returned `visible.length` whenever the cursor was anywhere ON the
    // last item — meant to make the 3-item-list "drop on last to send
    // it to the bottom" gesture more permissive. Reverted: it broke the
    // common "drop between second-to-last and last" path (hovering the
    // last item's upper-half no longer resolved to "before last"). With
    // the v0.2.6 indicator-visibility upgrade (3 px stroke + terminal
    // caps + accent glow) the lower-half-targets-end behavior is now
    // visually obvious, so the standard midline test reads cleanly
    // without the carve-out.
    if (y < r.top + r.height / 2) return i;
  }
  // Cursor is below every item's midline (and below last's top — only
  // possible if the section has padding-bottom and cursor is in that gap).
  return visible.length;
}

export const draggableListItem = defineTrait({
  name: 'draggable-list-item',
  category: 'motion-positioning',
  description: 'Pointer drag for list reordering; emits dnd-lift/drop-target-change/drop/drop-cancel',
  attributes: [ATTR_LIFTING, ATTR_SOURCE_ID],
  events: ['dnd-lift', 'dnd-drop-target-change', 'dnd-drop', 'dnd-drop-cancel'],
  // Opt-in: when present, the trait renders a positioned clone of the
  // host that follows the cursor during the drag (Pragmatic-DnD style
  // ghost preview). Backward-compatible — absent = no ghost, current
  // behavior. Style the ghost via consumer CSS using the
  // [data-draggable-list-item-ghost-active] attribute.
  config: [ATTR_GHOST],
  setup({ host }) {
    let id = host.getAttribute(ATTR_SOURCE_ID);
    if (!id) {
      id = `dlitem-${crypto.randomUUID().slice(0, 8)}`;
      host.setAttribute(ATTR_SOURCE_ID, id);
    }

    /** @type {{ x: number; y: number; pointerId: number; sourceCtx: ReturnType<typeof readSourceContext>; sourceContainerId: string | null } | null} */
    let down = null;
    let lifted = false;
    /** @type {string | null} */
    let lastTargetId = null;
    let lastTargetIndex = -1;
    /** @type {HTMLElement | null} */
    let ghost = null;

    function makeGhost() {
      if (!host.hasAttribute(ATTR_GHOST)) return;
      const r = host.getBoundingClientRect();
      ghost = /** @type {HTMLElement} */ (host.cloneNode(true));
      // Strip everything that could trigger interactive state on the clone:
      // - trait IDs (registry hygiene)
      // - tabindex (would attract focus when appended to body, painting
      //   a focus-visible outline that double-traces the ghost)
      // - aria-* state (clone is presentational; the source remains the
      //   accessible focus target during drag)
      // - data-selected (visual selection state belongs to the source)
      // - id (no DOM-id duplicates)
      ghost.removeAttribute(ATTR_SOURCE_ID);
      ghost.removeAttribute(ATTR_LIFTING);
      ghost.removeAttribute('data-keyboard-reorderable-id');
      ghost.removeAttribute('data-keyboard-reorderable-lifting');
      ghost.removeAttribute('data-selected');
      ghost.removeAttribute('id');
      ghost.removeAttribute('tabindex');
      ghost.removeAttribute('aria-selected');
      ghost.setAttribute('aria-hidden', 'true');
      Object.assign(ghost.style, {
        position: 'fixed',
        left: `${r.left}px`,
        top: `${r.top}px`,
        width: `${r.width}px`,
        margin: '0',
        pointerEvents: 'none',
        // Belt-and-suspenders: explicitly suppress outline + animation
        // so the cloned element never paints a focus ring or competes
        // with the consumer's [data-…-ghost-active] CSS for animation.
        outline: 'none',
        // Top-layer-ish; avoid 2147483647 to leave room for genuine top-layer
        // surfaces (toasts, dialogs that may fire during drag).
        zIndex: '10000',
        transition: 'none',
      });
      ghost.setAttribute(ATTR_GHOST_ACTIVE, '');
      document.body.appendChild(ghost);
    }

    function moveGhost(x, y) {
      if (!ghost || !down) return;
      // Translate the ghost by the cursor delta from the original pointerdown
      // so the cursor stays at the same relative position inside the ghost.
      ghost.style.translate = `${x - down.x}px ${y - down.y}px`;
    }

    function removeGhost() {
      if (ghost?.parentNode) ghost.parentNode.removeChild(ghost);
      ghost = null;
    }

    function isDisabled() {
      return host.hasAttribute('disabled') || host.getAttribute('aria-disabled') === 'true';
    }

    function reset() {
      if (down && host.hasPointerCapture?.(down.pointerId)) {
        try { host.releasePointerCapture(down.pointerId); } catch { /* ignore */ }
      }
      down = null;
      lifted = false;
      lastTargetId = null;
      lastTargetIndex = -1;
      host.removeAttribute(ATTR_LIFTING);
      removeGhost();
      // Always remove the keydown listener — even on disconnect — so a
      // disposed trait can never keep firing Esc-cancel handlers against
      // stale closures.
      document.removeEventListener('keydown', onKeyDown, true);
      // Always remove the document-level fallback listeners too. They
      // catch the case where pointerup fires outside the host's bounds
      // and pointer capture didn't deliver it (browser quirks under
      // tab-blur, popup-open, focus-loss). Re-armed on the next lift.
      document.removeEventListener('pointerup', onDocPointerUp, true);
      document.removeEventListener('pointercancel', onDocPointerCancel, true);
      window.removeEventListener('blur', onWindowBlur);
    }

    /**
     * Pointer-event safety net — if the host's own pointerup doesn't
     * fire (browser bug, focus loss mid-drag, popup steals events),
     * the document-level handler catches the release and runs the
     * same cancel-or-drop logic. Idempotent with the host handler:
     * whichever fires first runs reset(); the other is gated by the
     * `down=null` short-circuit.
     */
    function onDocPointerUp(e) {
      if (!down || down.pointerId !== e.pointerId) return;
      onPointerUp(e);
    }
    function onDocPointerCancel(e) {
      if (!down || down.pointerId !== e.pointerId) return;
      if (lifted) {
        document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
          bubbles: true,
          composed: false,
          detail: { source_id: id, reason: 'pointer-cancel' },
        }));
      }
      reset();
    }
    /** Window blur during a drag → cancel. Covers Cmd-Tab / popup-blocker / fullscreen escape. */
    function onWindowBlur() {
      if (!down) return;
      if (lifted) {
        document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
          bubbles: true,
          composed: false,
          detail: { source_id: id, reason: 'blur' },
        }));
      }
      reset();
    }

    function onPointerDown(e) {
      if (isDisabled()) return;
      if (e.button !== 0 && e.pointerType === 'mouse') return;

      const sourceCtx = readSourceContext(host);
      if (!sourceCtx) return;

      down = {
        x: e.clientX,
        y: e.clientY,
        pointerId: e.pointerId,
        sourceCtx,
        sourceContainerId: readContainerId(sourceCtx.container),
      };
      // Hold the pointer so we keep getting move/up even outside the host.
      try { host.setPointerCapture(e.pointerId); } catch { /* ignore */ }
      document.addEventListener('keydown', onKeyDown, true);
      // Document-level safety net for pointerup / pointercancel. Capture
      // phase (true) so we beat any consumer that calls stopPropagation.
      // host's own pointerup/cancel still fire normally; whichever lands
      // first runs the cleanup, the other no-ops via the down-null guard.
      document.addEventListener('pointerup', onDocPointerUp, true);
      document.addEventListener('pointercancel', onDocPointerCancel, true);
      window.addEventListener('blur', onWindowBlur);
    }

    function onPointerMove(e) {
      if (!down) return;
      const dx = e.clientX - down.x;
      const dy = e.clientY - down.y;

      if (!lifted) {
        if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
        lifted = true;
        host.setAttribute(ATTR_LIFTING, '');
        // Stamp the ghost preview AFTER setting the lifting attribute so
        // the host's lifted state (opacity, etc.) is captured in the
        // clone snapshot — except: we explicitly removed ATTR_LIFTING from
        // the clone in makeGhost() so the ghost itself renders as a
        // non-lifted card. Net effect: source dims, ghost looks "alive".
        makeGhost();
        host.dispatchEvent(new CustomEvent('dnd-lift', {
          bubbles: true,
          composed: false,
          detail: {
            source_id: id,
            source_index: down.sourceCtx.index,
            source_container_id: down.sourceContainerId,
          },
        }));
        announceImmediate(`Picked up item. Press arrow keys to navigate. Press Space to drop. Press Esc to cancel.`);
      }

      // Update the ghost on every move (cheap — single style write).
      moveGhost(e.clientX, e.clientY);

      const targetEl = dropTargetFromPoint(e.clientX, e.clientY);
      const targetId = targetEl?.getAttribute('data-droppable-id') ?? null;
      const targetIdx = targetEl ? indexWithinTarget(targetEl, e.clientX, e.clientY, id) : -1;

      if (targetId !== lastTargetId || targetIdx !== lastTargetIndex) {
        lastTargetId = targetId;
        lastTargetIndex = targetIdx;
        document.dispatchEvent(new CustomEvent('dnd-drop-target-change', {
          bubbles: true,
          composed: false,
          detail: {
            source_id: id,
            target_container_id: targetId,
            target_index: targetIdx,
            pointer: { x: e.clientX, y: e.clientY },
          },
        }));
        if (targetId) announceDebounced(`Drop position ${targetIdx + 1} in ${targetId}.`);
      }
    }

    function onPointerUp(e) {
      if (!down) return;
      if (!lifted) { reset(); return; }

      const targetEl = dropTargetFromPoint(e.clientX, e.clientY);
      const targetId = targetEl?.getAttribute('data-droppable-id') ?? null;

      if (!targetId) {
        document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
          bubbles: true,
          composed: false,
          detail: { source_id: id, reason: 'off-target' },
        }));
        announceImmediate(`Drag cancelled. Item returned to its original position.`);
      } else {
        const targetIdx = indexWithinTarget(targetEl, e.clientX, e.clientY, id);
        document.dispatchEvent(new CustomEvent('dnd-drop', {
          bubbles: true,
          composed: false,
          detail: {
            source_id: id,
            source_index: down.sourceCtx.index,
            source_container_id: down.sourceContainerId,
            target_container_id: targetId,
            target_index: targetIdx,
          },
        }));
        announceImmediate(`Dropped item in ${targetId} at position ${targetIdx + 1}.`);
      }
      reset();
    }

    function onKeyDown(e) {
      if (e.key === 'Escape' && lifted) {
        document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
          bubbles: true,
          composed: false,
          detail: { source_id: id, reason: 'esc' },
        }));
        announceImmediate(`Drag cancelled. Item returned to its original position.`);
        reset();
      }
    }

    function onHostPointerCancel() {
      if (lifted) {
        document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
          bubbles: true,
          composed: false,
          detail: { source_id: id, reason: 'pointer-cancel' },
        }));
      }
      reset();
    }

    host.addEventListener('pointerdown', onPointerDown);
    host.addEventListener('pointermove', onPointerMove);
    host.addEventListener('pointerup', onPointerUp);
    host.addEventListener('pointercancel', onHostPointerCancel);

    return () => {
      host.removeEventListener('pointerdown', onPointerDown);
      host.removeEventListener('pointermove', onPointerMove);
      host.removeEventListener('pointerup', onPointerUp);
      host.removeEventListener('pointercancel', onHostPointerCancel);
      // reset() removes the document-level + keydown + window-blur
      // listeners and the host-level lifting/ghost state.
      reset();
      host.removeAttribute(ATTR_SOURCE_ID);
    };
  },
});
