<script lang="ts">
import type { Align, Side } from './utils';
import type { Middleware, Placement, ReferenceElement } from '@floating-ui/vue';
import type { PrimitiveProps } from '../../internal/primitive';

/**
 * The floating element positioned against the anchor. This is the workhorse of
 * the Popper building block: it runs Floating UI (offset, flip, shift, size,
 * arrow, hide) to place itself on the chosen side/alignment, keeps the position
 * updated on scroll/resize/layout shift, avoids collisions with the viewport or
 * a custom boundary, and exposes `--popper-*` CSS variables plus `data-side` /
 * `data-align` attributes for styling and transform-origin. Use the Popper parts
 * to build any anchored overlay — tooltips, popovers, menus, selects — where
 * content must follow a trigger and stay on-screen. Place it inside a
 * `PopperRoot` (so it can read the registered anchor) and emit `placed` once the
 * first position settles.
 */
export interface PopperContentProps extends PrimitiveProps {
  /** Preferred side of the anchor. @default 'bottom' */
  side?: Side;
  /** Distance in pixels from the anchor. @default 0 */
  sideOffset?: number;
  /** Flip to the opposite side on collision. @default true */
  sideFlip?: boolean;
  /** Preferred alignment against the anchor. @default 'center' */
  align?: Align;
  /** Offset in pixels from the alignment edge. @default 0 */
  alignOffset?: number;
  /** Flip alignment on collision. @default true */
  alignFlip?: boolean;
  /** Reposition to prevent boundary overflow. @default true */
  avoidCollisions?: boolean;
  /** Collision boundary element(s). @default [] */
  collisionBoundary?: Array<Element | null> | Element | null;
  /** Distance from boundary for collision detection. @default 0 */
  collisionPadding?: number | Partial<Record<Side, number>>;
  /** Padding between arrow and content edges. @default 0 */
  arrowPadding?: number;
  /** Hide arrow when it can't be centered. @default true */
  hideShiftedArrow?: boolean;
  /** Sticky behavior on the align axis. @default 'partial' */
  sticky?: 'always' | 'partial';
  /** Hide when anchor is fully occluded. @default false */
  hideWhenDetached?: boolean;
  /** CSS position strategy. @default 'fixed' */
  positionStrategy?: 'absolute' | 'fixed';
  /** Position update strategy. @default 'optimized' */
  updatePositionStrategy?: 'always' | 'optimized';
  /** Disable layout-shift-based position update. @default false */
  disableUpdateOnLayoutShift?: boolean;
  /** Force content to stay within the viewport. @default false */
  prioritizePosition?: boolean;
  /** Custom reference element, overrides the anchor. */
  reference?: ReferenceElement;
}

export interface PopperContentEmits {
  placed: [];
}
</script>

<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import {
  autoUpdate,
  flip,
  arrow as floatingUIArrow,
  hide,
  limitShift,
  offset,
  shift,
  size,
  useFloating,
} from '@floating-ui/vue';
import { computed, ref, shallowRef, useTemplateRef, watchEffect, watchPostEffect } from 'vue';
import { getSideAndAlignFromPlacement, isNotNull, transformOrigin } from './utils';
import { providePopperContentContext, usePopperRootContext } from './context';
import { useForwardExpose, useResizeObserver } from '@robonen/vue';

defineOptions({ inheritAttrs: false });

const {
  side = 'bottom',
  sideOffset = 0,
  sideFlip = true,
  align = 'center',
  alignOffset = 0,
  alignFlip = true,
  avoidCollisions = true,
  collisionBoundary = [],
  collisionPadding: collisionPaddingProp = 0,
  arrowPadding = 0,
  hideShiftedArrow = true,
  sticky = 'partial',
  hideWhenDetached = false,
  positionStrategy = 'fixed',
  updatePositionStrategy = 'optimized',
  disableUpdateOnLayoutShift = false,
  prioritizePosition = false,
  reference: referenceProp,
  as,
} = defineProps<PopperContentProps>();

const emit = defineEmits<PopperContentEmits>();

const rootContext = usePopperRootContext();
const { forwardRef, currentElement: contentElement } = useForwardExpose();

const floatingRef = useTemplateRef<HTMLElement>('floatingRef');
const arrow = shallowRef<HTMLElement>();

// Arrow size tracking via ResizeObserver (replaces useSize). The observer
// re-targets when the arrow element changes and tears down on scope dispose.
const arrowWidth = ref(0);
const arrowHeight = ref(0);

useResizeObserver(arrow, ([entry]) => {
  if (!entry) return;
  const borderBox = entry.borderBoxSize[0];
  if (borderBox) {
    arrowWidth.value = borderBox.inlineSize;
    arrowHeight.value = borderBox.blockSize;
  }
  else {
    const rect = (entry.target as HTMLElement).getBoundingClientRect();
    arrowWidth.value = rect.width;
    arrowHeight.value = rect.height;
  }
});

const desiredPlacement = computed<Placement>(
  () => (side + (align !== 'center' ? `-${align}` : '')) as Placement,
);

const collisionPadding = computed(() => {
  return typeof collisionPaddingProp === 'number'
    ? collisionPaddingProp
    : { top: 0, right: 0, bottom: 0, left: 0, ...collisionPaddingProp };
});

const boundary = computed(() => {
  return Array.isArray(collisionBoundary)
    ? collisionBoundary
    : [collisionBoundary];
});

const detectOverflowOptions = computed(() => ({
  padding: collisionPadding.value,
  boundary: boundary.value.filter(isNotNull),
  altBoundary: boundary.value.length > 0,
}));

const flipOptions = computed(() => ({
  mainAxis: sideFlip,
  crossAxis: alignFlip,
}));

const computedMiddleware = computed<Middleware[]>(() => [
  offset({
    mainAxis: sideOffset + arrowHeight.value,
    alignmentAxis: alignOffset,
  }),
  prioritizePosition
  && avoidCollisions
  && flip({ ...detectOverflowOptions.value, ...flipOptions.value }),
  avoidCollisions
  && shift({
    mainAxis: true,
    crossAxis: !!prioritizePosition,
    limiter: sticky === 'partial' ? limitShift() : undefined,
    ...detectOverflowOptions.value,
  }),
  !prioritizePosition
  && avoidCollisions
  && flip({ ...detectOverflowOptions.value, ...flipOptions.value }),
  size({
    ...detectOverflowOptions.value,
    apply: ({ elements, rects, availableWidth, availableHeight }) => {
      const { width: anchorWidth, height: anchorHeight } = rects.reference;
      const contentStyle = elements.floating.style;
      contentStyle.setProperty('--popper-available-width', `${availableWidth}px`);
      contentStyle.setProperty('--popper-available-height', `${availableHeight}px`);
      contentStyle.setProperty('--popper-anchor-width', `${anchorWidth}px`);
      contentStyle.setProperty('--popper-anchor-height', `${anchorHeight}px`);
    },
  }),
  arrow.value && floatingUIArrow({ element: arrow.value, padding: arrowPadding }),
  transformOrigin({ arrowWidth: arrowWidth.value, arrowHeight: arrowHeight.value }),
  hideWhenDetached && hide({ strategy: 'referenceHidden', ...detectOverflowOptions.value }),
] as Middleware[]);

const reference = computed(() => referenceProp ?? rootContext.anchor.value);

const { floatingStyles, placement, isPositioned, middlewareData } = useFloating(
  reference,
  floatingRef,
  {
    strategy: positionStrategy,
    placement: desiredPlacement,
    whileElementsMounted: (...args) => {
      return autoUpdate(...args, {
        layoutShift: !disableUpdateOnLayoutShift,
        animationFrame: updatePositionStrategy === 'always',
      });
    },
    middleware: computedMiddleware,
  },
);

const placedSide = computed(() => getSideAndAlignFromPlacement(placement.value)[0]);
const placedAlign = computed(() => getSideAndAlignFromPlacement(placement.value)[1]);

watchPostEffect(() => {
  if (isPositioned.value) emit('placed');
});

const shouldHideArrow = computed(() => {
  const cannotCenterArrow = middlewareData.value.arrow?.centerOffset !== 0;
  return hideShiftedArrow && cannotCenterArrow;
});

const contentZIndex = shallowRef('');
watchEffect(() => {
  if (contentElement.value) {
    contentZIndex.value = globalThis.getComputedStyle(contentElement.value).zIndex;
  }
});

const arrowX = computed(() => middlewareData.value.arrow?.x ?? 0);
const arrowY = computed(() => middlewareData.value.arrow?.y ?? 0);

// Memoize the floating wrapper style. The template re-renders on every position
// update frame while open; a computed isolates the per-frame allocation to one
// tracked site and avoids re-running the [x,y].join(' ') and conditional reads
// when the render is triggered by an unrelated dep.
const wrapperStyle = computed(() => {
  const md = middlewareData.value;
  const referenceHidden = md.hide?.referenceHidden;
  return {
    ...floatingStyles.value,
    transform: isPositioned.value ? floatingStyles.value.transform : 'translate(0, -200%)',
    minWidth: 'max-content',
    zIndex: contentZIndex.value,
    '--popper-transform-origin': [
      md.transformOrigin?.x,
      md.transformOrigin?.y,
    ].join(' '),
    // Always set both keys so V8 keeps a single hidden class for the style
    // object across renders, instead of allocating a new shape when the
    // conditional spread kicks in.
    visibility: referenceHidden ? ('hidden' as const) : undefined,
    pointerEvents: referenceHidden ? ('none' as const) : undefined,
  };
});

providePopperContentContext({
  placedSide,
  onArrowChange: (element: HTMLElement | undefined) => { arrow.value = element; },
  arrowX,
  arrowY,
  shouldHideArrow,
});
</script>

<template>
  <div
    ref="floatingRef"
    data-popper-content-wrapper=""
    :style="wrapperStyle"
  >
    <Primitive
      :ref="forwardRef"
      v-bind="$attrs"
      :as="as"
      :data-side="placedSide"
      :data-align="placedAlign"
      :style="{
        animation: isPositioned ? undefined : 'none',
      }"
    >
      <slot />
    </Primitive>
  </div>
</template>
