// Spatial controller navigation.
//
// Given a focused element ("source") and a direction (up/down/left/right) we pick
// the next focusable by physical screen position rather than by an authoring-time
// grid (row/col data attributes). The screen geometry is the source of truth that
// the player sees, so it is also what the controller should follow.
//
// The algorithm:
//   1. Filter candidates to those whose *center* is strictly past the source's
//      edge in the requested direction. Center-vs-edge avoids picking elements
//      that merely brush the source from the side.
//   2. Prefer candidates that overlap the source on the perpendicular axis. A
//      candidate sharing your row when you press right (or your column when you
//      press up/down) is almost always the right answer.
//   3. If no overlapping candidate exists, fall back to candidates inside a 60°
//      cone from the source center pointing in the requested direction. Bias
//      heavily against off-axis drift.
//   4. Remember the "preferred" perpendicular center while the player presses the
//      same axis repeatedly so vertical traversal doesn't slide sideways across
//      rows of unequal width.
//   5. Sidebar is treated as a separate region. Left-from-content escapes to the
//      sidebar only when there is no content target to the left; right-from-
//      sidebar always re-enters content; up/down inside the sidebar walks the
//      sidebar items.

const EPSILON = 1;
const MAX_FALLBACK_CONE_RADIANS = Math.PI / 3; // 60°

const getRect = (element) => {
  const rect = element.getBoundingClientRect();
  return {
    left: rect.left,
    top: rect.top,
    right: rect.right,
    bottom: rect.bottom,
    width: rect.width,
    height: rect.height,
    centerX: rect.left + rect.width / 2,
    centerY: rect.top + rect.height / 2,
  };
};

const isInDirection = (sourceRect, targetRect, direction) => {
  if (direction === "right") return targetRect.centerX > sourceRect.right - EPSILON;
  if (direction === "left") return targetRect.centerX < sourceRect.left + EPSILON;
  if (direction === "down") return targetRect.centerY > sourceRect.bottom - EPSILON;
  if (direction === "up") return targetRect.centerY < sourceRect.top + EPSILON;
  return false;
};

const perpendicularOverlap = (sourceRect, targetRect, direction) => {
  if (direction === "left" || direction === "right") {
    const start = Math.max(sourceRect.top, targetRect.top);
    const end = Math.min(sourceRect.bottom, targetRect.bottom);
    return Math.max(0, end - start);
  }
  const start = Math.max(sourceRect.left, targetRect.left);
  const end = Math.min(sourceRect.right, targetRect.right);
  return Math.max(0, end - start);
};

const primaryDistance = (sourceRect, targetRect, direction) => {
  if (direction === "right") return Math.max(0, targetRect.left - sourceRect.right);
  if (direction === "left") return Math.max(0, sourceRect.left - targetRect.right);
  if (direction === "down") return Math.max(0, targetRect.top - sourceRect.bottom);
  if (direction === "up") return Math.max(0, sourceRect.top - targetRect.bottom);
  return Number.POSITIVE_INFINITY;
};

const perpendicularDisplacement = (sourceRect, targetRect, direction) => {
  if (direction === "left" || direction === "right") {
    return Math.abs(targetRect.centerY - sourceRect.centerY);
  }
  return Math.abs(targetRect.centerX - sourceRect.centerX);
};

const offAxisAngle = (sourceRect, targetRect, direction) => {
  const dx = targetRect.centerX - sourceRect.centerX;
  const dy = targetRect.centerY - sourceRect.centerY;
  if (direction === "right") return Math.atan2(Math.abs(dy), Math.max(1, dx));
  if (direction === "left") return Math.atan2(Math.abs(dy), Math.max(1, -dx));
  if (direction === "down") return Math.atan2(Math.abs(dx), Math.max(1, dy));
  if (direction === "up") return Math.atan2(Math.abs(dx), Math.max(1, -dy));
  return Math.PI;
};

const scoreCandidate = (sourceRect, targetRect, direction, anchorPerpCenter) => {
  if (!isInDirection(sourceRect, targetRect, direction)) {
    return null;
  }

  const primary = primaryDistance(sourceRect, targetRect, direction);
  const overlap = perpendicularOverlap(sourceRect, targetRect, direction);
  const perpDisp = perpendicularDisplacement(sourceRect, targetRect, direction);

  let score;
  if (overlap > 0) {
    // Strong winner: candidate shares the perpendicular line with the source.
    // Primary distance dominates; tiny perpendicular drift breaks ties so the
    // most centered candidate wins.
    score = primary + perpDisp * 0.15;
  } else {
    // No overlap — only allow within a 60° cone in the requested direction.
    if (offAxisAngle(sourceRect, targetRect, direction) > MAX_FALLBACK_CONE_RADIANS) {
      return null;
    }
    // Heavy off-axis penalty plus a flat bias so an overlapping candidate that
    // is further away in the primary axis still beats this one.
    score = primary + perpDisp * 2.5 + 400;
  }

  if (anchorPerpCenter !== null && Number.isFinite(anchorPerpCenter)) {
    const anchorDrift = direction === "left" || direction === "right"
      ? Math.abs(targetRect.centerY - anchorPerpCenter)
      : Math.abs(targetRect.centerX - anchorPerpCenter);
    score += anchorDrift * 0.6;
  }

  return score;
};

const isElementInteractable = (element) => {
  if (element.dataset.disabled === "true") return false;
  if (element.getAttribute("aria-disabled") === "true") return false;
  if (element.hasAttribute("hidden")) return false;
  const rect = element.getBoundingClientRect();
  if (rect.width <= 0 || rect.height <= 0) return false;
  return true;
};

export const createNavigationManager = () => {
  let contract = null;
  let focusables = [];
  let focusedIndex = -1;

  // Tracks the perpendicular center the player is "anchored" to while they
  // repeat moves on the same axis. axis="x" while pressing up/down, axis="y"
  // while pressing left/right. Reset whenever the axis flips or focus crosses
  // regions (sidebar <-> content).
  let anchor = { axis: null, value: null };

  const decorateFocusable = (element) => {
    element.classList.remove("is-focused");
    element.tabIndex = -1;
  };

  const dispatchFocusChange = (element) => {
    const col = Number(element.dataset.col ?? 0);
    const row = Number(element.dataset.row ?? 0);
    document.documentElement.style.setProperty("--nebula-accent-line-x", `${20 + col * 110}px`);
    document.documentElement.style.setProperty("--nebula-focus-strength", "1");

    window.dispatchEvent(
      new CustomEvent("nebula-focus-change", {
        detail: {
          key: element.dataset.focusKey ?? null,
          row,
          col,
        },
      }),
    );
  };

  const applyFocus = (index) => {
    if (!focusables.length) {
      focusedIndex = -1;
      return;
    }

    const clamped = Math.max(0, Math.min(index, focusables.length - 1));

    focusables.forEach((focusable) => {
      focusable.element.classList.remove("is-focused");
      focusable.element.setAttribute("aria-selected", "false");
    });

    focusedIndex = clamped;
    const focused = focusables[focusedIndex]?.element;
    if (focused) {
      focused.classList.add("is-focused");
      focused.setAttribute("aria-selected", "true");
      focused.focus({ preventScroll: true });
      dispatchFocusChange(focused);
    }
  };

  const buildFocusables = () => {
    if (!contract?.focusRoot) {
      focusables = [];
      return;
    }

    const roots = [contract.focusRoot, ...(contract.extraFocusRoots ?? [])].filter(Boolean);
    const nodes = Array.from(
      new Set(roots.flatMap((root) => Array.from(root.querySelectorAll("[data-focusable='true']")))),
    );

    focusables = nodes
      .filter(isElementInteractable)
      .map((element) => {
        decorateFocusable(element);
        return {
          element,
          row: Number(element.dataset.row ?? 0),
          col: Number(element.dataset.col ?? 0),
          key: element.dataset.focusKey ?? "",
          region: element.dataset.navRegion ?? "content",
        };
      });

    focusables.forEach((focusable, index) => {
      focusable.index = index;
    });
  };

  const resolveDefaultFocus = () => {
    if (!focusables.length) return -1;

    if (contract?.defaultFocus) {
      const idx = focusables.findIndex((f) => f.element === contract.defaultFocus);
      if (idx >= 0) return idx;
    }

    const contentIdx = focusables.findIndex((f) => f.region !== "sidebar" && f.region !== "guide");
    return contentIdx >= 0 ? contentIdx : 0;
  };

  const findContentDefaultIndex = () => {
    if (contract?.defaultFocus) {
      const idx = focusables.findIndex((f) => f.element === contract.defaultFocus);
      if (idx >= 0 && focusables[idx].region !== "sidebar" && focusables[idx].region !== "guide") {
        return idx;
      }
    }
    return focusables.findIndex((f) => f.region !== "sidebar" && f.region !== "guide");
  };

  const findSidebarTargetIndex = () => {
    const activeIdx = focusables.findIndex(
      (f) => f.region === "sidebar" && f.element.classList.contains("is-active"),
    );
    if (activeIdx >= 0) return activeIdx;
    return focusables.findIndex((f) => f.region === "sidebar");
  };

  const findNextRegionIndex = (direction, region) => {
    const regionItems = focusables
      .map((focusable, index) => ({ focusable, index }))
      .filter(({ focusable }) => focusable.region === region);

    if (!regionItems.length) return null;

    regionItems.sort((a, b) => {
      const ra = a.focusable.element.getBoundingClientRect();
      const rb = b.focusable.element.getBoundingClientRect();
      return ra.top - rb.top;
    });

    const currentSlot = regionItems.findIndex(({ index }) => index === focusedIndex);
    if (currentSlot < 0) return null;

    const nextSlot = direction === "up" ? currentSlot - 1 : currentSlot + 1;
    return regionItems[nextSlot]?.index ?? null;
  };

  const findNextSidebarIndex = (direction) => findNextRegionIndex(direction, "sidebar");

  const findNextGuideIndex = (direction) => findNextRegionIndex(direction, "guide");

  const findBestContentSpatialIndex = (direction) => {
    const source = focusables[focusedIndex];
    if (!source) return -1;

    const sourceRect = getRect(source.element);

    const axisOfMove = direction === "up" || direction === "down" ? "x" : "y";
    const anchorPerpCenter = anchor.axis === axisOfMove ? anchor.value : null;

    let bestIndex = -1;
    let bestScore = Number.POSITIVE_INFINITY;

    focusables.forEach((candidate, idx) => {
      if (idx === focusedIndex) return;
      if (candidate.region === "sidebar" || candidate.region === "guide") return;

      const targetRect = getRect(candidate.element);
      const score = scoreCandidate(sourceRect, targetRect, direction, anchorPerpCenter);
      if (score === null) return;
      if (score < bestScore) {
        bestScore = score;
        bestIndex = idx;
      }
    });

    return bestIndex;
  };

  const updateAnchorForMove = (direction, sourceRect) => {
    const axisOfMove = direction === "up" || direction === "down" ? "x" : "y";
    if (anchor.axis !== axisOfMove) {
      anchor = {
        axis: axisOfMove,
        value: axisOfMove === "x" ? sourceRect.centerX : sourceRect.centerY,
      };
    }
  };

  const moveWithNebula = (direction) => {
    // The bundled @nebulaproject/core picker is opt-in. Our local spatial
    // navigation is the default everywhere so every view gets the same
    // predictable controller behavior unless a view explicitly asks for the
    // remote algorithm.
    if (contract?.useNebulaNavigation !== true) return null;

    const picker = contract?.nebulaNavigation?.pickBestCandidate;
    if (typeof picker !== "function") return null;

    const source = focusables[focusedIndex];
    if (!source) return null;

    const sourceRect = getRect(source.element);
    const candidates = focusables
      .filter((item) => item.index !== source.index)
      .map((item) => {
        const rect = getRect(item.element);
        return {
          id: item.key,
          x: rect.left,
          y: rect.top,
          width: rect.width,
          height: rect.height,
        };
      });

    const picked = picker(
      {
        id: source.key,
        x: sourceRect.left,
        y: sourceRect.top,
        width: sourceRect.width,
        height: sourceRect.height,
      },
      candidates,
      direction,
    );

    if (!picked?.id) return null;

    const nextIndex = focusables.findIndex((item) => item.key === picked.id);
    return nextIndex >= 0 ? nextIndex : null;
  };

  const move = (direction) => {
    if (!focusables.length || focusedIndex < 0) return;

    const source = focusables[focusedIndex];
    const sourceRect = getRect(source.element);

    // -- Guide panel (expanded shell navigation) ---------------------------
    if (source.region === "guide") {
      if (direction === "up" || direction === "down") {
        const next = findNextGuideIndex(direction);
        if (next !== null) applyFocus(next);
        return;
      }
      if (direction === "left" || direction === "right") {
        const lateral = focusables
          .map((focusable, index) => ({ focusable, index }))
          .filter(({ focusable }) => focusable.region === "guide")
          .filter(({ focusable }) => {
            const row = Number(focusable.element.dataset.row ?? 0);
            return row === Number(source.element.dataset.row ?? 0);
          })
          .sort((a, b) => {
            const ra = a.focusable.element.getBoundingClientRect();
            const rb = b.focusable.element.getBoundingClientRect();
            return ra.left - rb.left;
          });

        const slot = lateral.findIndex(({ index }) => index === focusedIndex);
        if (slot < 0) return;
        const nextSlot = direction === "left" ? slot - 1 : slot + 1;
        const next = lateral[nextSlot]?.index;
        if (next !== undefined) applyFocus(next);
      }
      return;
    }

    // -- Collapsed sidebar rail --------------------------------------------
    if (source.region === "sidebar") {
      if (direction === "up" || direction === "down") {
        const next = findNextSidebarIndex(direction);
        if (next !== null) applyFocus(next);
        return;
      }
      if (direction === "right") {
        const contentIdx = findContentDefaultIndex();
        if (contentIdx >= 0) {
          anchor = { axis: null, value: null };
          applyFocus(contentIdx);
        }
        return;
      }
      return;
    }

    // -- Optional external navigation hook (Nebula core) -------------------
    const nebulaIndex = moveWithNebula(direction);
    if (nebulaIndex !== null) {
      updateAnchorForMove(direction, sourceRect);
      applyFocus(nebulaIndex);
      return;
    }

    // -- Spatial search within content -------------------------------------
    const bestContentIdx = findBestContentSpatialIndex(direction);
    if (bestContentIdx >= 0) {
      updateAnchorForMove(direction, sourceRect);
      applyFocus(bestContentIdx);
      return;
    }

    // -- Edge of content: escape to sidebar when going left ----------------
    if (direction === "left") {
      const sidebarIdx = findSidebarTargetIndex();
      if (sidebarIdx >= 0) {
        anchor = { axis: null, value: null };
        applyFocus(sidebarIdx);
      }
      return;
    }

    // No candidate in this direction. Stay put — pressing again won't bounce
    // the player around to unrelated regions.
  };

  const mount = (nextContract) => {
    contract = nextContract;
    anchor = { axis: null, value: null };
    buildFocusables();
    applyFocus(resolveDefaultFocus());
  };

  const getFocusedElement = () => focusables[focusedIndex]?.element ?? null;

  return {
    mount,
    move,
    getFocusedElement,
  };
};
