import { type RefObject, useCallback } from "react";

/** A `[start, end)` character range into a step's original `description` string — same convention as `StepTechStepView.start`/`end`. */
export interface TextSelectionRange {
  start: number;
  end: number;
}

/**
 * Reads the browser's current text selection, translated into a
 * {@link TextSelectionRange} into a step's original `description` string —
 * the shape `POST /recipes/:id/steps/:stepId/corrections` expects (see
 * `SubmitTechStepCorrectionInput`, `packages/shared`).
 *
 * Works by walking up from each end of the selection's `Range` to the
 * nearest ancestor carrying a `data-offset` attribute — set by
 * `StepDescription`'s editable mode on every {@link DescriptionSegment}'s
 * own wrapping element (`<span>`/`<button>`, `StepDescription.tsx`), each
 * wrapping exactly its own text run and nothing else. `data-offset`'s value
 * is that segment's own absolute start offset into `description`; added to
 * the in-node offset the `Range` reports, this gives an exact absolute
 * offset without needing to serialize/re-measure any text.
 *
 * Deliberately *not* using the more common `Range.toString().length`-from-
 * the-container's-start technique for this problem — `StepDescription`
 * always renders a `Tooltip` bubble alongside a keyword segment's own
 * `<button>` (`Tooltip.tsx`, hidden via CSS, not removed from the DOM),
 * whose text would silently pad that count past any keyword segment,
 * corrupting every offset downstream of one.
 */
export function useTextSelection(containerRef: RefObject<HTMLElement | null>): {
  getSelectionRange: () => TextSelectionRange | null;
} {
  const getSelectionRange = useCallback((): TextSelectionRange | null => {
    const selection = window.getSelection();
    const container = containerRef.current;
    if (!selection || selection.isCollapsed || selection.rangeCount === 0 || !container) {
      return null;
    }

    const range = selection.getRangeAt(0);
    if (!container.contains(range.commonAncestorContainer)) return null;

    const start = resolveOffset(container, range.startContainer, range.startOffset);
    const end = resolveOffset(container, range.endContainer, range.endOffset);
    if (start === null || end === null || start === end) return null;

    return start < end ? { start, end } : { start: end, end: start };
  }, [containerRef]);

  return { getSelectionRange };
}

/**
 * Resolves `(node, nodeOffset)` (one end of a DOM `Range`, in the DOM's own
 * mixed node/character-index convention) to an absolute character offset
 * into `description`, or `null` if `node` isn't inside a segment
 * `StepDescription` wrapped with `data-offset` at all — a selection edge
 * that lands on whitespace/structure outside any segment shouldn't occur
 * given every segment is wrapped, but this degrades to "no valid
 * selection" rather than a wrong span or a crash if it somehow does.
 */
function resolveOffset(container: HTMLElement, node: Node, nodeOffset: number): number | null {
  // A `Range` boundary that lands exactly on a segment's own wrapping
  // element (rather than diving into its single Text child) reports
  // `nodeOffset` as a *child index* (`0` or `1`, since every segment wraps
  // exactly one Text node) — not a character offset. Resolved to the
  // matching character offset (segment start vs. segment end) up front, so
  // the walk below only ever deals in character offsets from here on.
  let charOffset = nodeOffset;
  if (node instanceof HTMLElement) {
    const textChild = node.firstChild;
    charOffset = nodeOffset > 0 ? (textChild?.textContent?.length ?? 0) : 0;
  }

  let current: Node | null = node;
  while (current && current !== container) {
    if (current instanceof HTMLElement && current.dataset.offset !== undefined) {
      return Number(current.dataset.offset) + charOffset;
    }
    current = current.parentNode;
  }
  return null;
}
