import { debounce } from 'es-toolkit';
import { PropsWithChildren, ReactElement, ReactNode, useContext, useEffect, useMemo, useState } from 'react';
import { StyleProp, View, ViewStyle } from 'react-native';
import { InView, IOContext } from '../intersection-observer';
import { noop } from '../utils/noop';
import { usePreservedCallback } from '../utils/usePreservedCallback';
import { useVisibility } from '../visibility';

interface Props {
  onImpressionStart?: () => void;
  onImpressionEnd?: () => void;
  enabled?: boolean;
  UNSAFE__impressFallbackOnMount?: boolean;
  style?: StyleProp<ViewStyle>;
  areaThreshold?: number;
  timeThreshold?: number;
  children?: ReactNode;
}

/**
 * @public
 * @category 화면 제어
 * @name ImpressionArea
 * @description
 * 특정 컴포넌트가 화면에 보이는지 여부를 감지해서 외부에 알려주는 컴포넌트예요. 이 컴포넌트를 사용해서 화면에 특정 컴포넌트가 보이면 로그를 수집하거나 애니메이션을 실행하는 구현을 쉽게 할 수 있어요.
 * 화면에 보이는지 여부는 `useVisibility`의 반환값과 뷰포트(Viewport) 내에 표시되었는 지 알려주는 `IOScrollView`와 `InView` 컴포넌트로 감지해요. React에서 `ScrollView`를 사용하면 뷰가 화면에 보이지 않더라도, `ImpressionArea`를 사용하면 해당 뷰가 실제로 화면에 보일때만 이벤트를 발생시킬 수 있어요.

::: info 유의하세요

`ImpressionArea`는 반드시 `IOScrollView` 안에서 사용해야 해요. 만약 `IOScrollView` 외부에서 사용해야 한다면, `UNSAFE__impressFallbackOnMount` 속성을 `true`로 설정해서 컴포넌트가 마운트될 때를 기준으로 감지할 수 있어요. 이 속성이 `false`로 설정된 상태에서 `IOScrollView` 외부에서 사용하면 `IOProviderMissingError`가 발생해요.

:::
 *
 * @param {() => void} [onImpressionStart] 자식 컴포넌트가 화면에 보일 때 실행되는 콜백함수예요.
 * @param {() => void} [onImpressionEnd] 자식 컴포넌트가 화면에 가려졌을 때 실행되는 콜백함수예요.
 * @param {boolean} [enabled=true] 화면에 보여졌다는 조건을 직접 제어하는 값이에요. 기본값은 `true` 에요. `false` 로 전달하면 화면에 보여져도 `onImpressionStart` 콜백 함수가 실행되지 않아요.
 * @param {number} [areaThreshold=0] 보여지는 영역의 비율을 설정하는 값이에요. 이 값보다 큰 비율로 컴포넌트가 화면에 나타나면 `onImpressionStart`를 호출해요.
 * 값은 0부터 1 사이의 숫자로 설정하며, 0으로 설정하면 컴포넌트의 1px이라도 보일 때 이벤트가 발생해요. 반대로, 1로 설정하면 컴포넌트가 100% 화면에 노출될 때만 이벤트가 호출돼요.
 * @param {number} [timeThreshold=0] 이 컴포넌트가 화면에 보인 후 `onImpressionStart` 호출될 때까지의 시간을 밀리초 단위로 설정해요.기본값은 `0`밀리초(0초)에요.
 * @param {ViewStyle} [style] `InView` 컴포넌트에 적용할 `style` 값이에요. 기본값은 `undefined`이고, `style`을 지정하고 싶을 때 사용해요.
 * @param {boolean} [UNSAFE__impressFallbackOnMount=false] 컴포넌트가 마운트될 때 즉시 화면에 나타난 것으로 간주할지 여부예요. 기본값은 `false`예요.
 * `IOScrollView`를 사용하지 않는 상황에서, 컴포넌트가 뷰포트(Viewport) 안에 있는지 판단할 수 없을 떼 유용해요. 예를 들어, `IOScrollView` 밖에 위치한 컴포넌트는 `true`로 설정하면 마운트 시점에 보여졌다고 판단해요.
 *
 * @returns {ReactElement} - 화면에 보이는 여부를 감지할 수 있는 컴포넌트가 반환돼요.
 * @example
 * 
 * ### 기본 사용 예시
 * 
 * ```tsx
 * import { useState } from 'react';
 * import { Button, Dimensions, Text, View } from 'react-native';
 * import { ImpressionArea, IOScrollView } from 'react-native-bedrock';
 * 
 * export default function ImpressionAreaExample() {
 *  const [isImpressionStart, setIsImpressionStart] = useState(false);
 * 
 *  return (
 *    <>
 *      <Text>{isImpressionStart ? 'Impression Start' : 'Impression End'}</Text>
 *        <IOScrollView
 *          style={{
 *            flex: 1,
 *            margin: 16,
 *            backgroundColor: 'white',
 *          }}
 *        >
 *        <View
 *          style={{
 *            height: Dimensions.get('screen').height,
 *            borderWidth: 1,
 *            borderColor: 'black',
 *          }}
 *        >
 *          <Text>Scroll to here</Text>
 *        </View>
 * 
 *        <ImpressionArea
 *          onImpressionStart={() => setIsImpressionStart(true)}
 *          onImpressionEnd={() => setIsImpressionStart(false)}
 *        >
 *          <Button title="Button" />
 *        </ImpressionArea>
 *      </IOScrollView>
 *    </>
 *  );
 * }
 * ```
 *
 * ### 마운트 시점에 감지하는 예시
 * 
 * `ImpressionArea`가 `IOScrollView`와 같은 컴포넌트 내부에 위치하지 않을 때, `UNSAFE__impressFallbackOnMount`를 `true`로 설정하면 컴포넌트가 마운트될 때 화면에 보여진 것으로 간주해요.
 * 
 * ```tsx
 * import { useState } from 'react';
 * import { Button, Dimensions, ScrollView, Text, View } from 'react-native';
 * import { ImpressionArea } from 'react-native-bedrock';
 * 
 * export default function ImpressionArea2Example() {
 *  const [isImpressionStart, setIsImpressionStart] = useState(false);
 * 
 *  return (
 *    <>
 *      <Text>{isImpressionStart ? 'Impression Start' : 'Impression End'}</Text>
 *      <ScrollView
 *        style={{
 *          flex: 1,
 *          margin: 16,
 *          backgroundColor: 'white',
 *        }}
 *      >
 *        <View
 *          style={{
 *            height: Dimensions.get('screen').height,
 *            borderWidth: 1,
 *            borderColor: 'black',
 *          }}
 *        >
 *          <Text>Scroll to here</Text>
 *        </View>
 * 
 *        <ImpressionArea
 *          UNSAFE__impressFallbackOnMount={true}
 *          onImpressionStart={() => setIsImpressionStart(true)}
 *          onImpressionEnd={() => setIsImpressionStart(false)}
 *        >
 *          <Button title="Button" />
 *        </ImpressionArea>
 *      </ScrollView>
 *    </>
 *  );
 * }
 * ```
 */
export function ImpressionArea(props: Props): ReactElement {
  const context = useContext(IOContext);

  if (context?.manager == null && props.UNSAFE__impressFallbackOnMount) {
    return <ImpressionAreaOnMount {...props} />;
  }

  return <ImpressionAreaWithIntersectionObserver {...props} />;
}

/**
 * @category Class
 * @kind class
 * @name IOProviderMissingError
 * @description
 * `ImpressionArea`가 `IOScrollView` 내부에서 사용되지 않았을 때 발생하는 에러예요.
 * 만약 `IOScrollView` 내부에서 사용하지 않았을 때 `ImpressionArea`에서 이 에러를 발생시키고 싶지 않다면, `UNSAFE__impressFallbackOnMount`를 `true`로 설정해야 해요.
 */
export class IOProviderMissingError extends Error {
  message =
    'ImpressionArea가 IOContext.Provider 밖에서 사용되었습니다. ' +
    '최상위 ScrollView가 @react-native-bedrock/core의 IOScrollView인지 확인해주세요.';
}

/**
 * @category Components
 * @kind function
 * @name ImpressionAreaOnMount
 * @description
 * 컴포넌트가 화면에 마운트(mount)될 때 `onImpressionStart`와 `onImpressionEnd`를 호출하는 컴포넌트예요.
 * `ImpressionArea`에서 `UNSAFE__impressFallbackOnMount`가 `true`일 때 사용해요.
 * `IOScrollView` 밖에서 `ImpressionArea`가 필요할 때 이 컴포넌트를 사용할 수 있어요.
 *
 *
 * @param {() => void} [onImpressionStart] - 컴포넌트가 마운트될 때 호출되는 콜백 함수예요.
 * @param {() => void} [onImpressionEnd] - 컴포넌트가 언마운트될 때 호출되는 콜백 함수예요.
 * @param {StyleProp<ViewStyle>} [style] - `ImpressionAreaOnMount` 컴포넌트의 최상위 컨테이너 스타일을 설정해요.
 * @param {ReactNode} [children] - `ImpressionAreaOnMount`로 감싸는 자식 컴포넌트를 설정해요.
 * @returns {ReactElement} - 마운트 여부를 감지하는 컴포넌트를 반환해요
 * @example
 * ### 마운트 시점에 감지하는 예시
 * ```tsx
 * import { ImpressionAreaOnMount } from '@react-native-bedrock/core';
 *
 * export default function AlwaysImpressionAreaPage() {
 *   // AlwaysImpressionAreaPage가 mount되는 순간 isImpressionStart가 true로 설정됩니다.
 *   const [isImpressionStart, setIsImpressionStart] = useState(false);
 *
 *   return (
 *     <ScrollView
 *       style={{
 *         flex: 1,
 *         padding: 16,
 *         backgroundColor: 'white',
 *       }}
 *     >
 *       <TestTitle title="ImpressionArea" description="@react-native-bedrock/impression-area" />
 *       <Button label={'Scroll to origin'} onPress={() => {}} />
 *       <Code style={{ width, height }} json={{ isImpressionStart }} />
 *       <ImpressionAreaOnMount
 *         onImpressionStart={() => setIsImpressionStart(true)}
 *         onImpressionEnd={() => setIsImpressionStart(false)}
 *       >
 *         <Button label={'Scroll to here'} onPress={() => {}} />
 *       </ImpressionArea>
 *     </ScrollView>
 *   );
 * }
 * ```
 */
export function ImpressionAreaOnMount({
  onImpressionStart: _onImpressionStart = noop,
  onImpressionEnd: _onImpressionEnd = noop,
  style,
  children,
}: PropsWithChildren<Props>): ReactElement {
  const visible = useVisibility();

  const isImpressed = visible;

  const onImpressionStart = usePreservedCallback(_onImpressionStart);
  const onImpressionEnd = usePreservedCallback(_onImpressionEnd);

  useEffect(() => {
    if (isImpressed) {
      onImpressionStart();
    } else {
      onImpressionEnd();
    }
  }, [isImpressed, onImpressionStart, onImpressionEnd]);

  return <View style={style}>{children}</View>;
}

/**
 * @category Components
 * @kind function
 * @name ImpressionAreaWithIntersectionObserver
 * @description
 * 사용자의 화면에 보이는지를 기준으로 `onImpressionStart`와 `onImpressionEnd`를 호출하는 컴포넌트예요.
 * `IOScrollView`에서 사용할 수 있는 컴포넌트로, 화면에 보이는지 여부를 외부에 전달할 수 있어요.
 *
 * @param {() => void} [onImpressionStart] - 화면에 보일 때 호출되는 콜백 함수예요.
 * @param {() => void} [onImpressionEnd] - 화면에서 사라질 때 호출되는 콜백 함수예요.
 * @param {boolean} [enabled=true] - `ImpressionArea`를 활성화할지 여부를 설정해요.
 * @param {StyleProp<ViewStyle>} [style] - `ImpressionAreaWithIntersectionObserver` 컴포넌트의 최상위 컨테이너 스타일을 설정해요.
 * @param {number} [areaThreshold=0] - 이 컴포넌트의 보여지는 영역이 설정된 비율을 넘으면 화면에 보이는 것으로 간주해요.
 * @param {number} [timeThreshold=0] - 이 컴포넌트가 화면에 보인 후 `onImpressionStart`, `onImpressionEnd` 호출될 때까지의 시간을 밀리초 단위로 설정해요. 기본값은 `0`밀리초에요.
 * @param {ReactNode} [children] - `ImpressionAreaWithIntersectionObserver`로 감싸는 자식 컴포넌트를 설정할 수 있어요.
 * @returns {ReactElement} - 화면에 보이는지를 감지할 수 있는 컴포넌트를 반환해요.
 * @example
 * ### IntersectionObserver를 사용한 예시
 * ```tsx
 * import { ImpressionAreaWithIntersectionObserver, IOScrollView } from '@react-native-bedrock/core';
 *
 * export default function ImpressionAreaPage() {
 *   const [isImpressionStart, setIsImpressionStart] = useState(false);
 *
 *   return (
 *     <IOScrollView
 *       style={{
 *         flex: 1,
 *         margin: 16,
 *         backgroundColor: 'white',
 *       }}
 *     >
 *       <TestTitle title="ImpressionArea" description="@react-native-bedrock/impression-area" />
 *       <Button label={'Scroll to origin'} onPress={() => {}} />
 *       <Code style={{ width, height }} textStyle={{ flex: 1, verticalAlign: 'middle' }} json={{ isImpressionStart }} />
 *       <ImpressionAreaWithIntersectionObserver
 *         onImpressionStart={() => setIsImpressionStart(true)}
 *         onImpressionEnd={() => setIsImpressionStart(false)}
 *       >
 *         <Button label={'Scroll to here'} onPress={() => {}} />
 *       </ImpressionArea>
 *     </IOScrollView>
 *   );
 * }
 * ```
 */
export function ImpressionAreaWithIntersectionObserver({
  onImpressionStart: _onImpressionStart = noop,
  onImpressionEnd: _onImpressionEnd = noop,
  timeThreshold = 0,
  ...props
}: PropsWithChildren<{
  onImpressionStart?: () => void;
  onImpressionEnd?: () => void;
  enabled?: boolean;
  style?: StyleProp<ViewStyle>;
  areaThreshold?: number;
  timeThreshold?: number;
}>): ReactElement {
  const [isImpressed, setIsImpressed] = useState(false);

  const onImpressionChange = useMemo(() => {
    if (timeThreshold === 0) {
      return setIsImpressed;
    } else {
      return debounce(setIsImpressed, timeThreshold);
    }
  }, [setIsImpressed, timeThreshold]);

  const onImpressionStart = usePreservedCallback(_onImpressionStart);
  const onImpressionEnd = usePreservedCallback(_onImpressionEnd);

  useEffect(() => {
    if (isImpressed) {
      onImpressionStart?.();
    } else {
      onImpressionEnd?.();
    }
  }, [isImpressed, onImpressionStart, onImpressionEnd]);

  return <_ImpressionArea {...props} onImpressionChange={onImpressionChange} />;
}

// eslint-disable-next-line @typescript-eslint/naming-convention
function _ImpressionArea({
  children,
  onImpressionChange: _onImpressionChange = noop,
  enabled = true,
  areaThreshold = 0,
  style,
}: PropsWithChildren<{
  onImpressionChange?: (isImpressed: boolean) => void;
  enabled?: boolean;
  style?: StyleProp<ViewStyle>;
  areaThreshold?: number;
}>) {
  const visible = useVisibility();
  const [inviewImpressed, setInviewImpressed] = useState(false);

  const context = useContext(IOContext);

  if (context?.manager == null) {
    throw new IOProviderMissingError();
  }

  const impressed = visible && inviewImpressed && enabled;
  const onImpressionChange = usePreservedCallback(_onImpressionChange);

  useEffect(() => {
    onImpressionChange?.(impressed);
  }, [impressed, onImpressionChange]);

  return (
    <InView
      onChange={(inView, currentThreshold) => {
        const isVisible = inView && currentThreshold >= areaThreshold;
        setInviewImpressed(isVisible);
      }}
      style={style}
    >
      {children}
    </InView>
  );
}
