/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import type React from 'react';
import {
  useState,
  useRef,
  useCallback,
  useMemo,
  useLayoutEffect,
  useEffect,
  useId,
} from 'react';
import { Box, ResizeObserver, type DOMElement } from 'ink';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { useScrollable } from '../../contexts/ScrollProvider.js';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
import { Command } from '../../key/keyMatchers.js';
import { useOverflowActions } from '../../contexts/OverflowContext.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';

interface ScrollableProps {
  children?: React.ReactNode;
  width?: number;
  height?: number | string;
  maxWidth?: number;
  maxHeight?: number;
  hasFocus: boolean;
  scrollToBottom?: boolean;
  flexGrow?: number;
  reportOverflow?: boolean;
  overflowToBackbuffer?: boolean;
  scrollbar?: boolean;
  stableScrollback?: boolean;
}

export const Scrollable: React.FC<ScrollableProps> = ({
  children,
  width,
  height,
  maxWidth,
  maxHeight,
  hasFocus,
  scrollToBottom,
  flexGrow,
  reportOverflow = false,
  overflowToBackbuffer,
  scrollbar = true,
  stableScrollback,
}) => {
  const keyMatchers = useKeyMatchers();
  const [scrollTop, setScrollTop] = useState(0);
  const viewportRef = useRef<DOMElement | null>(null);
  const contentRef = useRef<DOMElement | null>(null);
  const overflowActions = useOverflowActions();
  const id = useId();
  const [size, setSize] = useState({
    innerHeight: typeof height === 'number' ? height : 0,
    scrollHeight: 0,
  });
  const sizeRef = useRef(size);
  const scrollTopRef = useRef(scrollTop);

  useLayoutEffect(() => {
    sizeRef.current = size;
  }, [size]);

  useLayoutEffect(() => {
    scrollTopRef.current = scrollTop;
  }, [scrollTop]);

  useEffect(() => {
    if (reportOverflow && size.scrollHeight > size.innerHeight) {
      overflowActions?.addOverflowingId?.(id);
    } else {
      overflowActions?.removeOverflowingId?.(id);
    }
  }, [
    reportOverflow,
    size.scrollHeight,
    size.innerHeight,
    id,
    overflowActions,
  ]);

  useEffect(
    () => () => {
      overflowActions?.removeOverflowingId?.(id);
    },
    [id, overflowActions],
  );

  const viewportObserverRef = useRef<ResizeObserver | null>(null);
  const contentObserverRef = useRef<ResizeObserver | null>(null);

  useEffect(
    () => () => {
      viewportObserverRef.current?.disconnect();
      contentObserverRef.current?.disconnect();
    },
    [],
  );

  const viewportRefCallback = useCallback((node: DOMElement | null) => {
    viewportObserverRef.current?.disconnect();
    viewportRef.current = node;

    if (node) {
      const observer = new ResizeObserver((entries) => {
        const entry = entries[0];
        if (entry) {
          const innerHeight = Math.round(entry.contentRect.height);
          setSize((prev) => {
            const scrollHeight = prev.scrollHeight;
            const isAtBottom =
              scrollHeight > prev.innerHeight &&
              scrollTopRef.current >= scrollHeight - prev.innerHeight - 1;

            if (isAtBottom) {
              setScrollTop(Number.MAX_SAFE_INTEGER);
            }
            return { ...prev, innerHeight };
          });
        }
      });
      observer.observe(node);
      viewportObserverRef.current = observer;
    }
  }, []);

  const contentRefCallback = useCallback(
    (node: DOMElement | null) => {
      contentObserverRef.current?.disconnect();
      contentRef.current = node;

      if (node) {
        const observer = new ResizeObserver((entries) => {
          const entry = entries[0];
          if (entry) {
            const scrollHeight = Math.round(entry.contentRect.height);
            setSize((prev) => {
              const innerHeight = prev.innerHeight;
              const isAtBottom =
                prev.scrollHeight > innerHeight &&
                scrollTopRef.current >= prev.scrollHeight - innerHeight - 1;

              if (
                isAtBottom ||
                (scrollToBottom && scrollHeight > prev.scrollHeight)
              ) {
                setScrollTop(Number.MAX_SAFE_INTEGER);
              }
              return { ...prev, scrollHeight };
            });
          }
        });
        observer.observe(node);
        contentObserverRef.current = observer;
      }
    },
    [scrollToBottom],
  );

  const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);

  const scrollBy = useCallback(
    (delta: number) => {
      const { scrollHeight, innerHeight } = sizeRef.current;
      const maxScroll = Math.max(0, scrollHeight - innerHeight);
      const current = Math.min(getScrollTop(), maxScroll);
      let next = Math.max(0, current + delta);
      if (next >= maxScroll) {
        next = Number.MAX_SAFE_INTEGER;
      }
      setPendingScrollTop(next);
      setScrollTop(next);
    },
    [getScrollTop, setPendingScrollTop],
  );

  const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
    useAnimatedScrollbar(hasFocus, scrollBy);

  useKeypress(
    (key: Key) => {
      const { scrollHeight, innerHeight } = sizeRef.current;
      const scrollTop = getScrollTop();
      const maxScroll = Math.max(0, scrollHeight - innerHeight);
      const actualScrollTop = Math.min(scrollTop, maxScroll);

      // Only capture scroll-up events if there's room;
      // otherwise allow events to bubble.
      if (actualScrollTop > 0) {
        if (keyMatchers[Command.PAGE_UP](key)) {
          scrollByWithAnimation(-innerHeight);
          return true;
        }
        if (keyMatchers[Command.SCROLL_UP](key)) {
          scrollByWithAnimation(-1);
          return true;
        }
      }

      // Only capture scroll-down events if there's room;
      // otherwise allow events to bubble.
      if (actualScrollTop < maxScroll) {
        if (keyMatchers[Command.PAGE_DOWN](key)) {
          scrollByWithAnimation(innerHeight);
          return true;
        }
        if (keyMatchers[Command.SCROLL_DOWN](key)) {
          scrollByWithAnimation(1);
          return true;
        }
      }

      // bubble keypress
      return false;
    },
    { isActive: hasFocus },
  );

  const getScrollState = useCallback(() => {
    const maxScroll = Math.max(0, size.scrollHeight - size.innerHeight);
    return {
      scrollTop: Math.min(getScrollTop(), maxScroll),
      scrollHeight: size.scrollHeight,
      innerHeight: size.innerHeight,
    };
  }, [getScrollTop, size.scrollHeight, size.innerHeight]);

  const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);

  const scrollableEntry = useMemo(
    () => ({
      // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
      ref: viewportRef as React.RefObject<DOMElement>,
      getScrollState,
      scrollBy: scrollByWithAnimation,
      hasFocus: hasFocusCallback,
      flashScrollbar,
    }),
    [getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar],
  );

  useScrollable(scrollableEntry, true);

  return (
    <Box
      ref={viewportRefCallback}
      maxHeight={maxHeight}
      width={width ?? maxWidth}
      height={height}
      flexDirection="column"
      overflowY="scroll"
      overflowX="hidden"
      scrollTop={scrollTop}
      flexGrow={flexGrow}
      scrollbarThumbColor={scrollbarColor}
      overflowToBackbuffer={overflowToBackbuffer}
      scrollbar={scrollbar}
      stableScrollback={stableScrollback}
    >
      {/*
        This inner box is necessary to prevent the parent from shrinking
        based on the children's content. It also adds a right padding to
        make room for the scrollbar.
      */}
      <Box
        ref={contentRefCallback}
        flexShrink={0}
        paddingRight={1}
        flexDirection="column"
      >
        {children}
      </Box>
    </Box>
  );
};
