import { useAnimationFrame } from 'framer-motion';
import React, {
  RefObject,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import { useInterval } from 'usehooks-ts';

import applyAutoScrollLimiting from '../edit2025/canvasRenderer/applyAutoScrollLimiting';
import useFrameCountRef from '../edit2025/canvasRenderer/useFrameCountRef';
import { MAX_PX_PER_BEAT, MIN_PX_PER_BEAT } from './constants';
import {
  getSelectionEndBeats,
  getSelectionStartBeats,
  getSongEndBeats,
  getSongStartBeats,
  getStudioDisplayedEndBeats,
  getStudioDisplayedStartBeats,
} from './selectors';
import { StudioProjectState } from './types';
import useFocusMemory from './useFocusMemory';

export const useStudioTimelineController = (
  mode: 'edit' | 'studio',
  stateRef: RefObject<StudioProjectState>,
  playing: boolean,
  getCurrentBeats: () => number
) => {
  return useTimelineController(
    mode,
    useCallback(
      () =>
        mode === 'studio'
          ? getStudioDisplayedStartBeats(stateRef.current)
          : getSongStartBeats(stateRef.current),
      [stateRef]
    ),
    useCallback(
      () =>
        mode === 'studio'
          ? getStudioDisplayedEndBeats(stateRef.current)
          : getSongEndBeats(stateRef.current),
      [stateRef]
    ),
    useCallback(() => getSelectionStartBeats(stateRef.current), [stateRef]),
    useCallback(() => getSelectionEndBeats(stateRef.current), [stateRef]),
    playing,
    getCurrentBeats
  );
};

const getDefaultWrapperRect = () => new DOMRect(0, 0, 1024, 768);

export type ContextMenuConfigType<T extends object> = {
  clientCoords: {
    top?: number;
    left?: number;
    right?: number;
    bottom?: number;
  };
  otherProps?: T;
  ChildComponent: React.FC<{ onClose: () => void } & T>;
};

export default function useTimelineController(
  mode: 'edit' | 'studio',
  getSongStartBeats: () => number,
  getSongEndBeats: () => number,
  getSelectionStartBeats: () => number,
  getSelectionEndBeats: () => number,
  playing: boolean,
  getCurrentBeats: () => number
) {
  const frameCountRef = useFrameCountRef();
  const contextualGenerateButtonHoveredRef = useRef(false);
  const wrapperRef = useRef<HTMLDivElement>(null);
  const [followMode, setFollowMode] = useState(false);

  /**
   * Latest mouse position relative to the canvas wrapper.
   * Keeping this in a ref prevents React re-renders on every mouse-move.
   */
  const canvasRelativeMousePositionRef = useRef<{ x: number; y: number }>({
    x: -Infinity,
    y: -Infinity,
  });

  const getWrapperRect = useCallback(() => {
    if (!wrapperRef.current) return getDefaultWrapperRect();
    return wrapperRef.current.getBoundingClientRect();
  }, []);
  const trackHeaderListRef = useRef<HTMLDivElement>(null);
  const trackHeadersRef = useRef<Record<string, HTMLDivElement>>({});
  const trackListHeightRef = useRef(0);

  const updateTrackHeadersRef = useCallback(() => {
    const list = trackHeaderListRef.current;
    if (!list) return;
    const trackHeaders = list.querySelectorAll('[data-track-header]');
    trackHeadersRef.current = (
      Array.from(trackHeaders) as HTMLDivElement[]
    ).reduce(
      (a, c) => ({
        ...a,
        [c.dataset.trackHeader!]: c,
      }),
      {}
    );
    frameCountRef.current++;
  }, []);

  const receiveTrackHeader = useCallback(() => {
    updateTrackHeadersRef();
  }, [updateTrackHeadersRef]);

  useInterval(() => {
    updateTrackHeadersRef();
  }, 1000);

  const getTimelineStartBeats = useCallback((paddingPx: number = 0) => {
    return (scrollXRef.current - paddingPx) / pxPerBeatRef.current;
  }, []);

  const getTimelineEndBeats = useCallback((paddingPx: number = 0) => {
    return (
      (scrollXRef.current + getWrapperRect().width + paddingPx) /
      pxPerBeatRef.current
    );
  }, []);

  const pxPerBeatRef = useRef(100);
  const scrollXRef = useRef(0);
  const scrollYRef = useRef(0);
  const lastTrackHeaderListScroller = useRef<HTMLDivElement | null>(null);

  const getTrackHeaderListScrollHeight = useCallback(() => {
    if (!lastTrackHeaderListScroller.current?.isConnected) {
      lastTrackHeaderListScroller.current =
        trackHeaderListRef.current?.querySelector(
          '.track-list-container > div'
        ) || null;
    }
    return lastTrackHeaderListScroller.current?.scrollHeight ?? 0;
  }, []);

  const fixBounds = useCallback(() => {
    applyAutoScrollLimiting(
      scrollXRef,
      pxPerBeatRef,
      getWrapperRect(),
      getSongStartBeats(),
      getSongEndBeats(),
      zoomAboutSelectionOrCenter,
      mode === 'edit' ? 100 : 64
    );
    trackListHeightRef.current = getTrackHeaderListScrollHeight();
    scrollYRef.current = Math.max(
      0,
      Math.min(
        scrollYRef.current,
        trackListHeightRef.current - getWrapperRect().height
      )
    );
    frameCountRef.current++;
  }, [
    getSongStartBeats,
    getSongEndBeats,
    getWrapperRect,
    pxPerBeatRef,
    scrollXRef,
    scrollYRef,
    trackListHeightRef,
    frameCountRef,
    getTrackHeaderListScrollHeight,
  ]);

  useEffect(() => {
    frameCountRef.current++;
  }, [playing, frameCountRef]);

  useAnimationFrame(
    useCallback(() => {
      if (!playing) return;
      frameCountRef.current++;
      if (followMode) {
        scrollXRef.current =
          getCurrentBeats() * pxPerBeatRef.current - getWrapperRect().width / 2;
      }
    }, [playing, frameCountRef, followMode, fixBounds])
  );

  const getMinimumDraggableContentDurationBeats = useCallback(() => {
    // whatever 4 px corresponds to in beats
    return 4 / pxPerBeatRef.current;
  }, [pxPerBeatRef]);

  const zoomAboutSelectionOrCenter = useCallback(
    (factor: number) => {
      const timelineWrapper = wrapperRef.current;
      if (!timelineWrapper) {
        return;
      }

      const timelineWidth = timelineWrapper.offsetWidth;
      const timelineScroll = scrollXRef.current;
      const timelineCenter = timelineScroll + timelineWidth / 2;
      const centerBeat = timelineCenter / pxPerBeatRef.current;
      let targetBeat = centerBeat;
      const selectionStartBeats = getSelectionStartBeats();
      const selectionEndBeats = getSelectionEndBeats();

      const boundedSelectionStartX = Math.max(
        0,
        Math.min(
          timelineWidth,
          selectionStartBeats * pxPerBeatRef.current - scrollXRef.current
        )
      );
      const boundedSelectionEndX = Math.max(
        0,
        Math.min(
          timelineWidth,
          selectionEndBeats * pxPerBeatRef.current - scrollXRef.current
        )
      );
      const selectionCenterX =
        (boundedSelectionStartX + boundedSelectionEndX) / 2;

      if (selectionCenterX !== 0 && selectionCenterX !== timelineWidth) {
        // selection is on-screen, zoom into it.
        targetBeat =
          (selectionCenterX + scrollXRef.current) / pxPerBeatRef.current;
      }

      pxPerBeatRef.current *= factor;

      const newCenter = targetBeat * pxPerBeatRef.current;
      const pxToScroll = newCenter - timelineCenter;
      scrollXRef.current += pxToScroll;
      frameCountRef.current++;

      fixBounds();
    },
    [
      frameCountRef,
      scrollXRef,
      getSelectionStartBeats,
      getSelectionEndBeats,
      fixBounds,
    ]
  );

  const showBeats = useCallback(
    (startBeats: number, endBeats: number, zoom: boolean = false) => {
      const wrapperWidth = getWrapperRect().width;
      if (zoom) {
        pxPerBeatRef.current = Math.max(
          MIN_PX_PER_BEAT,
          Math.min(
            (wrapperWidth - 40) / (endBeats - startBeats),
            MAX_PX_PER_BEAT
          )
        );
      }
      const midBeats = (startBeats + endBeats) / 2;
      const pxPerBeat = pxPerBeatRef.current;
      const timelineEndBeats = getTimelineEndBeats();
      const timelineStartBeats = getTimelineStartBeats();
      if (endBeats > timelineEndBeats || startBeats < timelineStartBeats) {
        scrollXRef.current = midBeats * pxPerBeat - wrapperWidth / 2;
      }
      if (
        !zoom &&
        (startBeats < timelineStartBeats || endBeats > timelineEndBeats)
      ) {
        scrollXRef.current = startBeats * pxPerBeat - 20;
      }
      applyAutoScrollLimiting(
        scrollXRef,
        pxPerBeatRef,
        getWrapperRect(),
        zoom ? startBeats : getSongStartBeats(),
        zoom ? endBeats : getSongEndBeats(),
        zoomAboutSelectionOrCenter,
        zoom ? 200 : 100
      );
    },
    [
      zoomAboutSelectionOrCenter,
      getWrapperRect,
      getSongStartBeats,
      getSongEndBeats,
    ]
  );

  const handleZoom = useCallback(
    (xFactor: number, _yFactor: number, xScrollAdjustment: number) => {
      const pxPerBeatBefore = pxPerBeatRef.current;
      pxPerBeatRef.current = pxPerBeatBefore * xFactor;

      if (
        Number.isNaN(pxPerBeatRef.current) ||
        !Number.isFinite(pxPerBeatRef.current) ||
        pxPerBeatRef.current <= 0
      ) {
        pxPerBeatRef.current = pxPerBeatBefore;
        return;
      }

      if (pxPerBeatBefore !== pxPerBeatRef.current) {
        frameCountRef.current++;
        scrollXRef.current += xScrollAdjustment;
      }

      fixBounds();
    },
    [frameCountRef, pxPerBeatRef, scrollXRef, fixBounds]
  );

  const handleScroll = useCallback(
    (x: number, y: number) => {
      scrollXRef.current += x;
      scrollYRef.current += y;
      frameCountRef.current++;

      fixBounds();
    },
    [frameCountRef, scrollXRef, fixBounds]
  );

  const resetZoom = useCallback(() => {
    const wrapperRect = getWrapperRect();
    const songStartBeats = getSongStartBeats();
    const songEndBeats = getSongEndBeats();
    if (songEndBeats !== undefined) {
      pxPerBeatRef.current = Math.max(
        0.0001,
        wrapperRect.width / (songEndBeats - songStartBeats)
      );
      scrollXRef.current = getSongStartBeats() * pxPerBeatRef.current - 250;
      frameCountRef.current++;
    }
    fixBounds();
  }, [getSongStartBeats, getSongEndBeats, getWrapperRect, fixBounds]);

  const interactingInTimelineRef = useRef(false);

  const onFocusTimeline = useFocusMemory(
    useCallback(() => {
      interactingInTimelineRef.current = true;
    }, []),
    useCallback(() => {
      interactingInTimelineRef.current = false;
    }, [])
  );

  const [
    timelineAndTrackHeadersContainer,
    setTimelineAndTrackHeadersContainer,
  ] = useState<HTMLDivElement | null>(null);
  const receiveTimelineAndTrackHeadersContainer = useCallback(
    (el: HTMLDivElement | null) => {
      setTimelineAndTrackHeadersContainer(el);
      if (!el) return;

      el.addEventListener('mousedown', onFocusTimeline);

      return () => {
        el.removeEventListener('mousedown', onFocusTimeline);
      };
    },
    [onFocusTimeline]
  );

  /**
   * Called by StudioTimeline. We wire the element up with a
   * mousemove listener that keeps canvasRelativeMousePositionRef
   * up-to-date without going through React state.
   */
  const receiveWrapperRef = useCallback((el: HTMLDivElement | null) => {
    wrapperRef.current = el;
    if (!el) return;
    const handleMouseMove = (evt: MouseEvent) => {
      const rect = el.getBoundingClientRect();
      canvasRelativeMousePositionRef.current = {
        x: evt.clientX - rect.left,
        y: evt.clientY - rect.top,
      };
      /* notify CanvasRenderer that something changed */
      frameCountRef.current += 1;
    };

    el.addEventListener('mousemove', handleMouseMove);
    return () => {
      el.removeEventListener('mousemove', handleMouseMove);
    };
  }, []);

  const [contextMenuConfig, setContextMenuConfig] =
    useState<ContextMenuConfigType<any> | null>(null);

  const setContextMenuConfigAndRegisterCloseListener = useCallback(
    <T extends object>(config: ContextMenuConfigType<T> | null) => {
      setContextMenuConfig(config);
      const onMouseDown = (e: MouseEvent) => {
        if (
          (e.target instanceof HTMLElement || e.target instanceof SVGElement) &&
          e.target.closest('[data-context-menu]')
        ) {
          return;
        }
        setContextMenuConfig(null);
        document.removeEventListener('mousedown', onMouseDown);
      };
      setTimeout(() => document.addEventListener('mousedown', onMouseDown), 0);
    },
    []
  );

  const resetTimeoutRef = useRef<NodeJS.Timeout | null>(null);

  const hasResetRef = useRef(false);
  useEffect(() => {
    if (hasResetRef.current) return;
    if (resetTimeoutRef.current) {
      clearTimeout(resetTimeoutRef.current);
    }
    resetTimeoutRef.current = setTimeout(() => {
      if (!hasResetRef.current) {
        hasResetRef.current = true;
        resetZoom();
      }
    }, 250);
  }, [resetZoom]);

  const afterLastTrackRef = useRef<HTMLDivElement>(null);

  return useMemo(
    () => ({
      wrapperRef,
      trackHeaderListRef,
      afterLastTrackRef,
      getWrapperRect,
      trackHeadersRef,
      frameCountRef,
      pxPerBeatRef,
      scrollXRef,
      scrollYRef,
      zoomAboutSelectionOrCenter,
      handleZoom,
      handleScroll,
      resetZoom,
      receiveTrackHeader,
      getTimelineStartBeats,
      getTimelineEndBeats,
      getMinimumDraggableContentDurationBeats,
      canvasRelativeMousePositionRef,
      receiveWrapperRef,
      contextMenuConfig,
      setContextMenuConfig: setContextMenuConfigAndRegisterCloseListener,
      showBeats,
      interactingInTimelineRef,
      fixBounds,
      receiveTimelineAndTrackHeadersContainer,
      timelineAndTrackHeadersContainer,
      followMode,
      setFollowMode,
      trackListHeightRef,
      contextualGenerateButtonHoveredRef,
    }),
    [
      wrapperRef,
      trackHeaderListRef,
      afterLastTrackRef,
      getWrapperRect,
      trackHeadersRef,
      frameCountRef,
      pxPerBeatRef,
      scrollXRef,
      scrollYRef,
      zoomAboutSelectionOrCenter,
      handleZoom,
      handleScroll,
      resetZoom,
      receiveTrackHeader,
      getTimelineStartBeats,
      getTimelineEndBeats,
      getMinimumDraggableContentDurationBeats,
      canvasRelativeMousePositionRef,
      receiveWrapperRef,
      contextMenuConfig,
      setContextMenuConfigAndRegisterCloseListener,
      showBeats,
      fixBounds,
      receiveTimelineAndTrackHeadersContainer,
      timelineAndTrackHeadersContainer,
      followMode,
      setFollowMode,
      trackListHeightRef,
      contextualGenerateButtonHoveredRef,
    ]
  );
}
