<!-- SPEC: PJ2026-010405 云端控制台. -->
<!-- Implementation reference: draft-2026-07-13-p0-cloud-console. -->

<script setup lang="ts" generic="T extends object">
import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  watch,
} from "vue";

const props = withDefaults(defineProps<{
  items: T[];
  itemKey: (item: T) => string;
  itemHeight: number;
  height?: number | string;
  overscan?: number;
  windowLimit?: number;
  ariaLabel?: string;
  rootRole?: string;
  itemRole?: string;
}>(), {
  height: 420,
  overscan: 5,
  ariaLabel: "虚拟列表",
  rootRole: "list",
  itemRole: "listitem"
});

const scrollTop = ref(0);
const viewportHeight = ref(typeof props.height === "number" ? props.height : 0);
const root = ref<HTMLElement | null>(null);
let resizeObserver: ResizeObserver | null = null;
const startIndex = computed(() => Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.overscan));
const endIndex = computed(() => {
  const requestedEnd = Math.ceil((scrollTop.value + viewportHeight.value) / props.itemHeight) + props.overscan;
  const limitedEnd = props.windowLimit && props.windowLimit > 0
    ? Math.min(requestedEnd, startIndex.value + props.windowLimit)
    : requestedEnd;
  return Math.min(props.items.length, limitedEnd);
});
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value));
const totalHeight = computed(() => props.items.length * props.itemHeight);
const windowOffset = computed(() => startIndex.value * props.itemHeight);
const rootStyle = computed(() => ({ height: typeof props.height === "number" ? `${props.height}px` : props.height }));

function readViewportHeight(): void {
  const height = root.value?.clientHeight ?? 0;
  if (height > 0 && height !== viewportHeight.value) viewportHeight.value = height;
}

function stopObservingViewport(): void {
  resizeObserver?.disconnect();
  resizeObserver = null;
  if (typeof window !== "undefined") {
    window.removeEventListener("resize", readViewportHeight);
  }
}

function observeViewport(): void {
  stopObservingViewport();
  readViewportHeight();
  if (!root.value) return;
  if (typeof ResizeObserver !== "undefined") {
    resizeObserver = new ResizeObserver(readViewportHeight);
    resizeObserver.observe(root.value);
    return;
  }
  if (typeof window !== "undefined") {
    window.addEventListener("resize", readViewportHeight, { passive: true });
  }
}

onMounted(observeViewport);
onBeforeUnmount(stopObservingViewport);

watch(
  () => props.height,
  async (height) => {
    if (typeof height === "number") viewportHeight.value = height;
    await nextTick();
    readViewportHeight();
  },
);

function onScroll(event: Event): void {
  const target = event.currentTarget;
  if (!(target instanceof HTMLElement)) return;
  scrollTop.value = target.scrollTop;
  viewportHeight.value = target.clientHeight;
}

async function scrollToIndex(index: number, align: "nearest" | "start" | "end" = "nearest"): Promise<void> {
  const target = Math.max(0, Math.min(props.items.length - 1, index));
  const top = target * props.itemHeight;
  const bottom = top + props.itemHeight;
  const viewportBottom = scrollTop.value + viewportHeight.value;
  const nextScrollTop = align === "start" || top < scrollTop.value
    ? top
    : align === "end" || bottom > viewportBottom
      ? Math.max(0, bottom - viewportHeight.value)
      : scrollTop.value;
  scrollTop.value = nextScrollTop;
  if (root.value) root.value.scrollTop = nextScrollTop;
  await nextTick();
}

defineExpose({ scrollToIndex });
</script>

<template>
  <div ref="root" class="console-virtual-list" :style="rootStyle" :role="rootRole" :aria-label="ariaLabel" @scroll.passive="onScroll">
    <div class="console-virtual-list-spacer" :style="{ height: `${totalHeight}px` }">
      <div class="console-virtual-list-window" :style="{ transform: `translateY(${windowOffset}px)` }">
        <div v-for="(item, localIndex) in visibleItems" :key="itemKey(item)" :role="itemRole" :style="{ height: `${itemHeight}px` }">
          <slot :item="item" :index="startIndex + localIndex" />
        </div>
      </div>
    </div>
  </div>
</template>
