import React, {
  forwardRef,
  ReactNode,
  useCallback,
  useEffect,
  useImperativeHandle,
  useMemo,
  useRef,
  useState,
} from 'react';
import {View, ViewStyle, StyleProp, Pressable} from 'react-native';
import {useTranslation} from 'react-i18next';
import Pinchable from 'react-native-pinchable';
import {
  Image as ExpoImage,
  ImageProps as ExpoImageProps,
  ImageSource as ExpoImageSource,
  ImageErrorEventData,
} from 'expo-image';
import {Pressable as RNGHPressable} from 'react-native-gesture-handler';
import {useNetInfo} from '@react-native-community/netinfo';
import {Spinner} from '@components/_HuGo/Spinner';
import {useGetAuthHeaders} from '@hooks/useGetAuthHeaders';
import {useTheme} from '@shared/theme';

import {ImageSk} from '../ImageSk';
import {styles} from './styles';
import {extractUriFromSource, getCacheKey} from './utils';

enum ImageStatus {
  Loading = 'LOADING',
  Error = 'ERROR',
  Success = 'SUCCESS',
}

export type ImageSource = ExpoImageSource;
export type ImageCachePolicy = 'none' | 'disk' | 'memory' | 'memory-disk';

/** Imperative API exposed via ref. */
export interface ImageHandle {
  /**
   * Force ExpoImage to re-fetch the current source (bypasses the cache).
   * Resets the internal auto-retry counter so the new attempt gets a fresh
   * 5 silent retries before propagating onError to the consumer.
   */
  reload: () => Promise<void>;
}

/** Auto-retry budget before propagating onError to the caller. */
const AUTO_RETRY_LIMIT = 5;

export interface ImageProps
  extends Omit<ExpoImageProps, 'source' | 'cachePolicy'> {
  containerStyle?: StyleProp<ViewStyle>;
  isLoading?: boolean;
  isError?: boolean;
  loaderType?: 'skeleton' | 'spinner' | 'none';
  onLongPress?: () => void;
  onPress?: () => void;
  pinchable?: boolean;
  renderError?: ReactNode;
  source: ImageSource;
  withAuthHeader?: boolean;
  enableCache?: boolean;
  /**
   * Override the ExpoImage cache tier. Defaults to `'disk'` when
   * `enableCache` is true, `'none'` otherwise. Pass `'memory'` for
   * caches that don't need to survive a restart (e.g. avatars).
   */
  cachePolicy?: ImageCachePolicy;
  /**
   * Timeout in milliseconds for image loading. You can avoid the timeout by setting it to 0.
   * @default 15 seconds.
   */
  timeout?: number | 'no-timeout';
  useRNGHPressable?: boolean;
}

export const Image = forwardRef<ImageHandle, ImageProps>(function Image(
  {
    containerStyle,
    isLoading,
    isError,
    loaderType = 'none',
    onLongPress,
    onPress,
    pinchable,
    renderError,
    source,
    style,
    withAuthHeader,
    onError,
    enableCache = false,
    cachePolicy,
    timeout = 30000,
    accessibilityLabel,
    useRNGHPressable = false,
    placeholder,
    contentFit,
    placeholderContentFit,
    ...props
  },
  ref,
) {
  // Forwarded ref to the underlying ExpoImage so we can call its
  // imperative `reloadAsync` from inside this component (auto-retry on
  // error) and expose a `reload()` method to the consumer (manual retry).
  const expoImageRef = useRef<ExpoImage>(null);
  // Counts silent reload attempts triggered by ExpoImage's onError. Once
  // it reaches AUTO_RETRY_LIMIT we stop retrying and propagate onError so
  // the consumer can show a visible retry affordance.
  const autoRetryCountRef = useRef(0);
  const {theme} = useTheme();
  const {t} = useTranslation();
  const timeoutRef = useRef<NodeJS.Timeout>(undefined);
  const url = useMemo(() => extractUriFromSource(source), [source]);
  // Local files decode essentially instantly — start optimistic so cache hits
  // don't flash a spinner. Remote URLs need a real fetch — start in Loading.
  const [status, setStatus] = useState<ImageStatus>(() =>
    url && url.startsWith('http') ? ImageStatus.Loading : ImageStatus.Success,
  );
  const firstRenderRef = useRef(true);
  const injectAuthHeader = withAuthHeader && url?.startsWith('http');
  const authHeaders = useGetAuthHeaders(injectAuthHeader);
  const cacheKey = useMemo(
    () => (enableCache ? getCacheKey(url) : undefined),
    [enableCache, url],
  );
  const sourceInfo: ExpoImageSource = useMemo(
    () =>
      url
        ? {
            uri: url,
            cacheKey,
            headers: injectAuthHeader && authHeaders ? authHeaders : undefined,
          }
        : (source as ExpoImageSource),
    [url, cacheKey, injectAuthHeader, authHeaders, source],
  );
  const hasSource = !!sourceInfo.uri;

  // Decorate the placeholder source the same way as the main source so it
  // gets auth headers + cacheKey alignment. ExpoImage paints the
  // placeholder while the main source loads; if it's already in cache
  // (e.g. small avatar from chat list) it appears in one frame.
  const placeholderSource = useMemo(() => {
    if (!placeholder) return undefined;
    const placeholderUri = extractUriFromSource(placeholder as ExpoImageSource);
    if (!placeholderUri) return placeholder;
    const placeholderIsHttp = placeholderUri.startsWith('http');
    return {
      uri: placeholderUri,
      cacheKey: enableCache ? getCacheKey(placeholderUri) : undefined,
      headers:
        withAuthHeader && placeholderIsHttp && authHeaders
          ? authHeaders
          : undefined,
    } satisfies ExpoImageSource;
  }, [placeholder, enableCache, withAuthHeader, authHeaders]);

  // Network state — we suspend retries / loader / error UI while offline
  // and resume on reconnect.
  const {isInternetReachable} = useNetInfo();
  // Treat `null` (initial / unknown) as online so the very first attempt
  // can run; only `false` is the explicit offline state.
  const isOffline = isInternetReachable === false;
  const isOfflineRef = useRef(isOffline);
  isOfflineRef.current = isOffline;

  const onLoad = useCallback(() => {
    autoRetryCountRef.current = 0;
    setStatus(ImageStatus.Success);
  }, []);

  const onErrorProxy = useCallback(
    (error: ImageErrorEventData) => {
      // While offline, swallow the error silently — the reconnect effect
      // below will trigger a reload once the network is back.
      if (isOfflineRef.current) return;
      // Silent auto-retry up to AUTO_RETRY_LIMIT before bothering the
      // consumer. Most transient failures (token-refresh race, brief
      // network blip) recover within one or two reloads.
      if (autoRetryCountRef.current < AUTO_RETRY_LIMIT) {
        autoRetryCountRef.current += 1;
        expoImageRef.current?.reloadAsync().catch(() => {});
        return;
      }
      onError?.(error);
      setStatus(ImageStatus.Error);
    },
    [onError],
  );

  // Reset the auto-retry budget whenever the source URL changes so a
  // brand-new asset gets its own AUTO_RETRY_LIMIT silent attempts.
  useEffect(() => {
    autoRetryCountRef.current = 0;
  }, [url]);

  // Reconnect handler: when the network comes back, reset the retry
  // budget and reload if we haven't successfully displayed yet.
  const wasOfflineRef = useRef(isOffline);
  useEffect(() => {
    if (wasOfflineRef.current && !isOffline) {
      autoRetryCountRef.current = 0;
      if (status !== ImageStatus.Success) {
        setStatus(ImageStatus.Loading);
        expoImageRef.current?.reloadAsync().catch(() => {});
      }
    }
    wasOfflineRef.current = isOffline;
  }, [isOffline, status]);

  useImperativeHandle(
    ref,
    () => ({
      reload: async () => {
        autoRetryCountRef.current = 0;
        setStatus(ImageStatus.Loading);
        await expoImageRef.current?.reloadAsync();
      },
    }),
    [],
  );

  // Per-attempt load timeout: armed when ExpoImage starts a fetch
  // (onLoadStart), cleared when the attempt resolves (onLoadEnd). If it
  // fires first, we treat it as a transient error so the auto-retry
  // funnel kicks in. Skipped while offline (no point firing a "timeout"
  // when we know the network isn't there).
  const onLoadStartProxy = useCallback(() => {
    if (timeout === 'no-timeout' || isOfflineRef.current) return;
    clearTimeout(timeoutRef.current);
    timeoutRef.current = setTimeout(() => {
      onErrorProxy({error: 'image load timeout'} as ImageErrorEventData);
    }, timeout);
  }, [timeout, onErrorProxy]);

  const onLoadEnd = useCallback(() => {
    clearTimeout(timeoutRef.current);
    timeoutRef.current = undefined;
  }, []);

  const ImageContainer = pinchable ? Pinchable : View;
  const resolvedLoading =
    isLoading !== undefined ? isLoading : status === ImageStatus.Loading;
  // While offline we suppress both the loader and the error overlay — the
  // user already knows the network is gone, and surfacing per-image
  // spinners / retry icons just adds noise. The reconnect effect above
  // will silently reload images that hadn't finished yet.
  const showError =
    !isOffline &&
    (isError !== undefined ? isError : status === ImageStatus.Error);
  // Show the loader while either (a) the hook is still waiting for a source,
  // or (b) we have a source but ExpoImage hasn't finished decoding it yet.
  // Case (a) is the initial download; case (b) covers the decode/display
  // window where a naive `resolvedLoading && !hasSource` gate would hide the
  // spinner before the image is actually on screen, leaving a blank tile.
  const showLoader =
    !isOffline &&
    !showError &&
    ((resolvedLoading && !hasSource) ||
      (hasSource && status === ImageStatus.Loading));
  const loaderStyle = [
    styles.loader,
    styles.image,
    {backgroundColor: theme.background.elements.brand},
    style,
  ];

  const LoaderRender = {
    spinner: (
      <View
        style={loaderStyle as StyleProp<ViewStyle>}
        // TODO: Improve accessibility label for image loading spinner
        accessibilityLabel={t('general.upload_loading')}
        accessibilityRole="progressbar">
        <Spinner />
      </View>
    ),
    skeleton: <ImageSk style={loaderStyle} />,
    none: null,
  };

  /**
   * Reset the internal status to Loading only on real post-mount url
   * transitions (e.g. the useChatFile hook going from `loading` → `success`
   * with a freshly downloaded file). The initial render keeps the optimistic
   * `Success` so cached/already-downloaded images rendered during fast
   * scroll don't flash a spinner before ExpoImage has had a chance to draw.
   */
  useEffect(() => {
    if (firstRenderRef.current) {
      firstRenderRef.current = false;
      return;
    }
    if (hasSource) {
      setStatus(ImageStatus.Loading);
    }
  }, [url, hasSource]);

  /**
   * Clear timeout when the component unmounts (if it exists). The
   * per-attempt timer is armed by `onLoadStartProxy` and cleared by
   * `onLoadEnd` during normal operation.
   */
  useEffect(() => {
    return () => {
      clearTimeout(timeoutRef.current);
    };
  }, []);

  const PressableComponent = useRNGHPressable ? RNGHPressable : Pressable;

  return (
    <PressableComponent
      disabled={!onPress && !onLongPress}
      onLongPress={onLongPress}
      onPress={onPress}
      style={containerStyle}
      accessibilityLabel={accessibilityLabel}
      accessibilityRole="button"
      accessible={onPress || onLongPress ? true : !showLoader}>
      <ImageContainer accessible={false}>
        {!hasSource || (injectAuthHeader && !authHeaders) ? (
          <View
            style={[styles.image, style as StyleProp<ViewStyle>]}
            accessible={false}
          />
        ) : (
          <ExpoImage
            {...props}
            ref={expoImageRef}
            source={sourceInfo}
            placeholder={placeholderSource}
            contentFit={contentFit}
            // expo-image's default `placeholderContentFit` is
            // `'scale-down'` while `contentFit` defaults to `'cover'` —
            // the mismatch makes a small placeholder appear at its
            // native size in the corner of a larger container. Force
            // them to match unless the caller explicitly overrides.
            placeholderContentFit={placeholderContentFit ?? contentFit}
            cachePolicy={cachePolicy ?? (enableCache ? 'disk' : 'none')}
            onError={onErrorProxy}
            onLoad={onLoad}
            onLoadStart={onLoadStartProxy}
            onLoadEnd={onLoadEnd}
            style={[styles.image, style]}
            accessible={false}
          />
        )}
      </ImageContainer>
      {showLoader && LoaderRender[loaderType]}
      {showError && renderError && (
        <View
          style={[styles.error, styles.image, style as StyleProp<ViewStyle>]}
          accessible={false}>
          {renderError}
        </View>
      )}
    </PressableComponent>
  );
});
