import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';

import type { CodeWalkthroughStepAttr } from '@redocly/config';
import type { ActiveStep, WalkthroughStepsState } from '../../types/code-walkthrough';
import type { MarkerArea } from '../../types/marker';

import { getAdjacentValues, insertAt, isBrowser, removeElement } from '../../utils/js-utils';
import { ACTIVE_STEP_QUERY_PARAM } from '../../constants/code-walkthrough';

type CodeWalkthroughStep = CodeWalkthroughStepAttr & {
  compRef?: HTMLElement;
  markerRef?: HTMLElement;
};

type StepWithIndex = CodeWalkthroughStep & {
  index: number;
};

type Params = {
  steps: CodeWalkthroughStep[];
  enableDeepLink: boolean;
  root: React.RefObject<HTMLDivElement | null>;
};

export function useCodeWalkthroughSteps({
  steps,
  enableDeepLink,
  root,
}: Params): WalkthroughStepsState {
  const location = useLocation();
  const navigate = useNavigate();
  const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search]);

  const observerRef = useRef<IntersectionObserver | null>(null);
  const filtersElementRef = useRef<HTMLDivElement>(null);
  const lockObserver = useRef<boolean>(false);
  // Track observed elements in case new observer needs to be created
  const observedElementsRef = useRef(new Set<HTMLElement>());

  const [activeStep, setActiveStep] = useState<ActiveStep>(
    enableDeepLink ? searchParams.get(ACTIVE_STEP_QUERY_PARAM) : null,
  );

  const stepsMap = useMemo(() => {
    const map = new Map<string, StepWithIndex>();
    steps.forEach((step, index) => {
      map.set(step.id, { ...step, index });
    });

    return map;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [JSON.stringify(steps)]);

  const options = useMemo(() => {
    if (!isBrowser()) {
      return null;
    }

    const filtersElementHeight = filtersElementRef.current?.clientHeight || 0;
    const navbarHeight = document.querySelector('nav')?.getBoundingClientRect().height || 0;

    return {
      filtersElementHeight,
      navbarHeight,
    };
  }, []);

  const [visibleSteps, setVisibleSteps] = useState<StepWithIndex[]>([]);

  const [markers, setMarkers] = useState<Record<string, MarkerArea>>({});

  useEffect(() => {
    if (!root.current || !visibleSteps.length || !options) {
      return;
    }

    const markersMinTopOffset = options.filtersElementHeight + options.navbarHeight;

    const rootHeight = root.current?.clientHeight ?? 0;
    const lastStepOffset = visibleSteps[visibleSteps.length - 1]?.compRef?.offsetTop ?? 0;
    const deficit = Math.max(lastStepOffset - (rootHeight - window.innerHeight), 0);

    const groups = getGroups(visibleSteps);
    let markers: number[] = groups.flatMap((group) => getGroupMarkers(group));

    if (deficit) {
      const startOffset = markersMinTopOffset;
      const endOffset = Math.max(rootHeight - window.innerHeight, 0);

      markers = distributeMarkers({
        endOffset,
        markers,
        startOffset: markersMinTopOffset < endOffset ? startOffset : 0,
      });
    }

    setMarkers(
      markers.reduce(
        (acc, marker, index) => {
          const step = visibleSteps[index];
          acc[step.id] = {
            offset: marker,
            height:
              markers[index + 1] || !step.compRef
                ? (markers[index + 1] ?? rootHeight) - marker
                : step.compRef.clientHeight,
          };
          return acc;
        },
        {} as Record<string, MarkerArea>,
      ),
    );

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [visibleSteps, root.current, options]);

  const registerMarker = useCallback(
    (stepId: string, element: HTMLElement) => {
      if (observerRef.current) {
        const step = stepsMap.get(stepId);
        if (step) {
          step.markerRef = element;
        }

        observerRef.current.observe(element);
        observedElementsRef.current.add(element);
      }
    },
    [stepsMap],
  );

  const removeMarker = useCallback(
    (stepId: string, element: HTMLElement) => {
      if (observerRef.current) {
        const step = stepsMap.get(stepId);
        if (step) {
          step.markerRef = undefined;
        }

        observerRef.current.unobserve(element);
        observedElementsRef.current.delete(element);
      }
    },
    [stepsMap],
  );

  const registerStep = useCallback(
    (stepId: string, element: HTMLElement) => {
      const step = stepsMap.get(stepId);
      if (!step) {
        return;
      }

      step.compRef = element;
      setVisibleSteps((prevSteps) => insertAt(prevSteps, step.index, step));
    },
    [stepsMap],
  );

  const removeStep = useCallback(
    (stepId: string) => {
      const step = stepsMap.get(stepId);
      if (!step) {
        return;
      }

      step.compRef = undefined;
      setVisibleSteps((prevSteps) => removeElement(prevSteps, step));
      setActiveStep((prevStep) => (prevStep === stepId ? null : prevStep));
    },
    [stepsMap],
  );

  const observerCallback = useCallback(
    (entries: IntersectionObserverEntry[]) => {
      if (lockObserver.current || !visibleSteps.length) {
        return;
      }

      if (visibleSteps.length < 2) {
        setActiveStep(visibleSteps[0]?.id || null);
        return;
      }

      for (const entry of entries) {
        const stepId = (entry.target as HTMLElement)?.dataset?.stepId;

        if (!stepId) {
          continue;
        }

        const { intersectionRatio, boundingClientRect, rootBounds, isIntersecting } = entry;
        const step = stepsMap.get(stepId);

        if (!step) {
          continue;
        }

        const stepIndex = visibleSteps.findIndex((renderedStep) => renderedStep.id === stepId);
        const { next } = getAdjacentValues(visibleSteps, stepIndex);

        const intersectionAtTop =
          rootBounds?.bottom !== undefined && boundingClientRect.top < rootBounds.top;
        const stepGoesIn = isIntersecting;

        if (intersectionRatio > 0.8 && intersectionRatio < 1 && intersectionAtTop) {
          setActiveStep(step.id);
          break;
        }

        if (intersectionRatio < 1 && intersectionRatio !== 0 && intersectionAtTop) {
          let newStep: string | null = null;

          if (stepGoesIn) {
            newStep = step.id;
          } else if (next) {
            newStep = next.id;
          }

          setActiveStep((prevStep) => newStep || prevStep);

          break;
        }
      }
    },
    [stepsMap, visibleSteps],
  );

  useEffect(() => {
    if (!options) {
      return;
    }

    const newObserver = new IntersectionObserver(observerCallback, {
      threshold: [0.3, 0.8, 0.9, 0.95],
      rootMargin: `-${options.filtersElementHeight + options.navbarHeight}px 0px 0px 0px`,
    });

    for (const observedElement of observedElementsRef.current.values()) {
      newObserver.observe(observedElement);
    }

    observerRef.current?.disconnect();
    observerRef.current = newObserver;
  }, [observerCallback, markers, options]);

  useEffect(() => {
    if (!options) {
      return;
    }

    const rootTopOffset = root.current?.offsetTop ?? 0;
    if (!activeStep && rootTopOffset <= options.navbarHeight) {
      setActiveStep(visibleSteps[0]?.id || null);
    }

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [activeStep, root.current, options, visibleSteps]);

  /**
   * Update the URL search params with the current state of the filters and inputs
   */
  useEffect(() => {
    if (!enableDeepLink) {
      return;
    }

    const newSearchParams = new URLSearchParams(Array.from(searchParams.entries()));

    if (activeStep) {
      newSearchParams.set(ACTIVE_STEP_QUERY_PARAM, activeStep);
    } else {
      newSearchParams.delete(ACTIVE_STEP_QUERY_PARAM);
    }

    const newSearch = newSearchParams.toString();
    if (newSearch === location.search.substring(1)) return;

    navigate({ search: newSearch }, { replace: true });

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [activeStep]);

  return {
    registerStep,
    removeStep,
    markers,
    registerMarker,
    removeMarker,
    lockObserver,
    filtersElementRef,
    activeStep,
    setActiveStep,
  };
}

type StepsGroup = {
  freeSpace: number;
  usedSpace: number;
  offset: number;
  steps: { offset: number; height: number; ref?: HTMLElement }[];
};
/**
 * This function analyzes the offset and height of each step to determine
 * when a new group should be created. A new group is started when there is a free space
 * between the two steps, treating it as the content of the next group header.
 *
 * @param steps - An array of `CodeWalkthroughStep` objects
 *
 * @returns An array of `StepsGroup` objects, each containing the offset from the top of the relative
 *          block, the free space at the top of the group, the total space used by the steps within the group
 *          and the steps themselves with relative offset and height.
 */
function getGroups(steps: CodeWalkthroughStep[]): StepsGroup[] {
  if (!steps.length) {
    return [];
  }

  const firstStepOffset = steps[0]?.compRef?.offsetTop ?? 0;
  const firstStepHeight = steps[0]?.compRef?.clientHeight ?? 0;
  const secondStepOffset = steps[1]?.compRef?.offsetTop ?? 0;
  const margin = Math.max(secondStepOffset - firstStepOffset - firstStepHeight, 0);

  let groupIndex = 0;
  const groups: StepsGroup[] = [
    {
      offset: 0,
      freeSpace: firstStepOffset,
      usedSpace: 0,
      steps: [],
    },
  ];

  for (let i = 0; i < steps.length; i++) {
    let currentGroup = groups[groupIndex];

    const step = steps[i];
    const stepHeight = step.compRef?.clientHeight ?? 0;
    const stepOffset = step.compRef?.offsetTop ?? 0;

    const prevStepOffset = currentGroup.freeSpace + currentGroup.usedSpace;

    if (prevStepOffset !== Math.max(stepOffset - currentGroup.offset, 0)) {
      const offset = currentGroup.offset + currentGroup.freeSpace + currentGroup.usedSpace;

      groupIndex++;
      groups[groupIndex] = {
        offset,
        freeSpace: Math.max(stepOffset - offset, 0),
        usedSpace: 0,
        steps: [],
      };
      currentGroup = groups[groupIndex];
    }

    currentGroup.steps.push({
      offset: stepOffset - currentGroup.offset,
      height: stepHeight,
      ref: step.compRef,
    });
    currentGroup.usedSpace += stepHeight + margin;
  }

  return groups;
}

export function getGroupMarkers(group: StepsGroup) {
  if (!group.steps.length) {
    return [];
  }

  if (group.steps.length === 1) {
    return [group.offset + group.steps[0].offset - group.freeSpace];
  }

  const availableFreeSpace =
    group.freeSpace > 0.3 * window.innerHeight ? 0.3 * window.innerHeight : group.freeSpace;
  const unusedFreeSpace = group.freeSpace - availableFreeSpace;
  const lastStepOffset = group.steps[group.steps.length - 1].offset;

  // distribute group free space between steps
  return distributeMarkers({
    startOffset: 0,
    endOffset: lastStepOffset - unusedFreeSpace,
    markers: group.steps.map((step) => step.offset),
    additionalSteps: [(marker) => group.offset + unusedFreeSpace + marker],
  });
}

/**
 * Distribute markers preserving the relationship throughout the available space
 * @param startOffset - the starting point of the available space
 * @param endOffset - the end point of the available space
 * @param markers - the markers to distribute
 * @param additionalSteps - additional steps to apply to the markers
 *
 * @returns array of markers positions
 */
function distributeMarkers({
  endOffset,
  markers,
  startOffset,
  additionalSteps = [],
}: {
  startOffset: number;
  endOffset: number;
  markers: number[];
  additionalSteps?: ((marker: number) => number)[];
}) {
  return markers.map((marker) => {
    const normalizedOffset = getNormalizedNumber({
      min: markers[0],
      max: markers[markers.length - 1],
      value: marker,
    });

    const availableSpace = endOffset - startOffset;

    let result = startOffset + normalizedOffset * availableSpace;

    for (const additionalStep of additionalSteps) {
      result = additionalStep(result);
    }

    return result;
  });
}

/**
 * Normalize a number between a min and max value
 * @param min - the minimum value of the distribution
 * @param max - the maximum value of the distribution
 * @param value - the value to normalize
 *
 * @returns normalized number between 0 and 1
 */
function getNormalizedNumber(options: { min: number; max: number; value: number }) {
  const { min, max, value } = options;

  return (value - min) / (max - min);
}
