// Port of shadcn's `message-scroller.tsx`'s `MessageScrollerViewport`. Wraps
// `@shadcn/react/message-scroller`'s `MessageScroller.Viewport`, which owns
// native scroll/wheel/touch/keydown listeners plus a `ResizeObserver` on the
// viewport element itself.
//
// The source attaches these as React synthetic event props
// (onScroll/onWheel/onTouchMove/onKeyDown) and a `useEffect`-mounted
// ResizeObserver. This port uses the same native events as plain `on*`
// attributes (Marko compiles these to real DOM listeners, so behavior is
// identical) and moves the ResizeObserver into a `<lifecycle>` block — the
// task's requested "real scroll listener + ResizeObserver in an effect"
// idiom, using this project's actual effect primitive (`<lifecycle>`; Marko
// has no separate `<effect>` tag).
//
// SSR STORY for `data-pending-scroll` (shadcn 503a3a5 port, see
// lib/controller.ts's `pendingDefaultScroll` comment and notes/
// style-ports.md's "pending-scroll" section): CLIENT-ONLY, not render-time.
// Upstream's React source computes `pendingDefaultScrollStore`'s initial
// value synchronously during render, so the server markup already carries
// the attribute. This port's controller is created lazily, browser-only (see
// message-scroller-provider.marko's SERIALIZATION RULE comment), so it
// cannot write anything into the SSR HTML — a static `defaultScrollPosition`
// input threaded past the controller would close that specific gap, but
// widens this component's input surface beyond controller.ts, which the
// task scope excludes.
//
// This does not reintroduce a visible flicker for a server-rendered,
// already-visible transcript: `message-scroller-provider.marko`'s
// `<const/controller>` closure lazily builds ONE controller shared by every
// part, and `setRootElement`/`setViewportElement`/`setContentElement` each
// call `tryInitialSync()`, which runs `handleContentChange()` exactly once,
// synchronously, the moment the LAST of the three parts registers during
// Marko's mount pass — before any `requestAnimationFrame` and before the
// browser paints. `applyDefaultScrollPosition()`'s `scrollTo`/`scrollTop`
// writes are themselves synchronous DOM assignments (no rAF), so for an
// SSR'd non-empty transcript the opening scroll position is already correct
// by the time this component's first paint happens; `data-pending-scroll`
// is set and cleared within that same synchronous call and a viewport whose
// position never needed to change never visibly toggles `invisible` at all.
// Content arriving AFTER an EMPTY mount is NOT protected, and this matches
// upstream exactly — not a gap in this port. Upstream's own
// `useLayoutEffect` (use-message-scroller-controller.ts, the "REACT REF"
// diff in 503a3a5) runs applyDefaultScrollPosition() once at mount and, if
// it returns false because itemCount is 0, immediately calls
// clearPendingDefaultScroll() right there — before any later message ever
// streams in. This port's tryInitialSync()/handleContentChange() gate
// mirrors that exact sequence (controller.ts's reconcileScrollPosition:
// `if (items.length === 0) clearPendingDefaultScroll();` on the first,
// empty, sync). So in both upstream and here, an initially-empty transcript
// permanently disarms pendingDefaultScroll at mount; a message streaming in
// afterward runs applyDefaultScrollPosition() again (still true — it
// re-checks defaultScrollPositionApplied, not the pending flag) and moves
// the viewport, but WITHOUT the attribute's hide, because upstream's own
// docs (message-scroller.mdx, "Avoiding a Flash on Reload") scope the
// feature to "a server-rendered transcript" specifically and say to skip
// even the companion inline unhide-script "when messages load on the
// client" — content streaming in live was never this feature's target.
import { cn } from "#lib/utils.ts";
import type { MessageScrollerControllerGetter } from "./lib/controller.ts";
import { USER_SCROLL_KEYS } from "./lib/types.ts";
import { viewport as styles } from "./classes.ts";

export interface Input extends Marko.Input<"div"> {
  /** Controller getter provided by the nearest `<message-scroller-provider>`. */
  controller: MessageScrollerControllerGetter;
  /** Keep the first visible messageId row stable on prepend. Defaults to true. */
  preserveScrollOnPrepend?: boolean;
}

<const/{
  controller,
  class: className,
  content,
  preserveScrollOnPrepend = true,
  role,
  tabindex,
  "aria-label": ariaLabel,
  ...rest
}=input/>

<script>
  controller().setPreserveScrollOnPrepend(preserveScrollOnPrepend);
</script>

<div/el
  ...rest
  role=(role ?? "region")
  aria-label=(ariaLabel ?? "Messages")
  tabindex=(tabindex ?? 0)
  data-slot="message-scroller-viewport"
  class=cn(styles.root, className)
  onWheel() {
    controller().userScrollIntent();
  }
  onTouchMove() {
    controller().userScrollIntent();
  }
  onKeyDown(event: KeyboardEvent) {
    if (USER_SCROLL_KEYS.has(event.key)) {
      controller().userScrollIntent();
    }
  }
>
  <${content}/>
</div>

<lifecycle
  onMount() {
    const viewport = el();
    controller().setViewportElement(viewport);

    // `scroll` does not bubble and never reaches Marko's document-level
    // delegated listeners, so an `onScroll` attribute here would be dead —
    // it must be attached directly to the element.
    const onScroll = () => controller().syncAfterScroll();
    viewport.addEventListener("scroll", onScroll, { passive: true });

    if (typeof ResizeObserver === "undefined") {
      return {
        cleanup: () => viewport.removeEventListener("scroll", onScroll),
      };
    }

    // Coalesce into rAF: handleResize mutates the spacer inside the observed
    // content, and resizing an observed element during delivery fires
    // "ResizeObserver loop completed with undelivered notifications" (same
    // rationale as the source).
    let frame = 0;
    const observer = new ResizeObserver(() => {
      window.cancelAnimationFrame(frame);
      frame = window.requestAnimationFrame(() => controller().handleResize());
    });
    observer.observe(viewport);

    // Marko merges onMount's RETURNED OBJECT into `this` (returning a bare
    // cleanup function is silently ignored) — onDestroy reads it back.
    return {
      cleanup: () => {
        viewport.removeEventListener("scroll", onScroll);
        window.cancelAnimationFrame(frame);
        observer.disconnect();
      },
    };
  }
  onDestroy() {
    this.cleanup?.();
    controller().setViewportElement(null);
  }
/>
