import { StaticRouter, type RouterProps } from "@solidjs/router";
import {
  Component,
  createContext,
  createRenderEffect,
  createUniqueId,
  Index,
  onMount,
  Show,
  untrack,
  useContext,
  type Accessor,
} from "solid-js";
import { createStore, unwrap } from "solid-js/store";
import "./StackedRouter.css";
import { animateSlideInFromRight, animateSlideOutToRight } from "./anim";
import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "../material/theme";
import {
  eventListener,
  makeEventListener,
} from "@solid-primitives/event-listener";

export type StackedRouterProps = Omit<RouterProps, "url">;

export type StackFrame = {
  path: string;
  rootId: string;
  state: unknown;

  animateOpen?: (element: HTMLElement) => Animation;
  animateClose?: (element: HTMLElement) => Animation;
};

export type NewFrameOptions<T> = (T extends undefined
  ? {
      state?: T;
    }
  : { state: T }) & {
  /**
   * The new frame should replace the current frame.
   */
  replace?: boolean;
  /**
   * The animatedOpen phase of the life cycle.
   *
   * You can use this hook to animate the opening
   * of the frame. In this phase, the frame content is created
   * and is mounted to the document.
   *
   * You must return an {@link Animation}. This function must be
   * without side effects. This phase is ended after the {@link Animation}
   * finished.
   */
  animateOpen?: StackFrame["animateOpen"];
  /**
   * The animatedClose phase of the life cycle.
   *
   * You can use this hook to animate the closing of the frame.
   * In this phase, the frame content is still mounted in the
   * document and will be unmounted after this phase.
   *
   * You must return an {@link Animation}. This function must be
   * without side effects. This phase is ended after the
   * {@link Animation} finished.
   */
  animateClose?: StackFrame["animateClose"];
};

export type FramePusher<T, K extends keyof T = keyof T> = T[K] extends
  | undefined
  | any
  ? (path: K, state?: Readonly<NewFrameOptions<T[K]>>) => Readonly<StackFrame>
  : (path: K, state: Readonly<NewFrameOptions<T[K]>>) => Readonly<StackFrame>;

export type Navigator<PushGuide = Record<string, any>> = {
  frames: readonly StackFrame[];
  push: FramePusher<PushGuide>;
  pop: (depth?: number) => void;
};

const NavigatorContext = /* @__PURE__ */ createContext<Navigator>();

export function useMaybeNavigator() {
  return useContext(NavigatorContext);
}

/**
 * Get the navigator of the {@link StackedRouter}.
 *
 * This function returns a {@link Navigator} without available
 * push guide. Push guide is a record type contains available
 * path and its state. If you need push guide, you may want to
 * define your own function (like `useAppNavigator`) and cast the
 * navigator to the type you need.
 */
export function useNavigator() {
  const navigator = useMaybeNavigator();

  if (!navigator) {
    throw new TypeError("not in available scope of StackedRouter");
  }

  return navigator;
}

export type CurrentFrame = {
  index: number;
  frame: Readonly<StackFrame>;
};

const CurrentFrameContext =
  /* @__PURE__ */ createContext<Accessor<Readonly<CurrentFrame>>>();

export function useMaybeCurrentFrame() {
  return useContext(CurrentFrameContext);
}

export function useCurrentFrame() {
  const frame = useMaybeCurrentFrame();

  if (!frame) {
    throw new TypeError("not in available scope of StackedRouter");
  }

  return frame;
}

/**
 * Return an accessor of is current frame is suspended.
 *
 * A suspended frame is the one not on the top. "Suspended"
 * is the description of a certain situtation, not in the life cycle
 * of a frame.
 */
export function useMaybeIsFrameSuspended() {
  const { frames } = useMaybeNavigator() || {};

  if (typeof frames === "undefined") {
    return () => false;
  }

  const thisFrame = useCurrentFrame();

  return () => {
    const idx = thisFrame().index;
    return frames.length - 1 > idx;
  };
}

function onDialogClick(
  onClose: () => void,
  event: MouseEvent & { currentTarget: HTMLDialogElement },
) {
  if (event.target !== event.currentTarget) return;
  const rect = event.currentTarget.getBoundingClientRect();
  const isNotInDialog =
    event.clientY < rect.top ||
    event.clientY > rect.bottom ||
    event.clientX < rect.left ||
    event.clientX > rect.right;
  if (isNotInDialog) {
    onClose();
  }
}

function animateClose(element: HTMLElement) {
  if (window.innerWidth <= 560) {
    return animateSlideOutToRight(element, { easing: ANIM_CURVE_DECELERATION });
  } else {
    return element.animate(
      {
        opacity: [0.5, 0],
      },
      { easing: ANIM_CURVE_STD, duration: 220 },
    );
  }
}

function animateOpen(element: HTMLElement) {
  if (window.innerWidth <= 560) {
    return animateSlideInFromRight(element, {
      easing: ANIM_CURVE_DECELERATION,
    });
  } else {
    return element.animate(
      {
        opacity: [0.5, 1],
      },
      { easing: ANIM_CURVE_STD, duration: 220 },
    );
  }
}

function serializableStack(stack: readonly StackFrame[]) {
  const frames = unwrap(stack);
  return frames.map((fr) => {
    return {
      path: fr.path,
      rootId: fr.rootId,
      state: fr.state,
    };
  });
}

/**
 * The router that stacks the pages.
 */
const StackedRouter: Component<StackedRouterProps> = (oprops) => {
  const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" });

  const pushFrame = (path: string, opts?: Readonly<NewFrameOptions<any>>) =>
    untrack(() => {
      const frame = {
        path,
        state: opts?.state,
        rootId: createUniqueId(),
        animateOpen: opts?.animateOpen,
        animateClose: opts?.animateClose,
      };

      mutStack(opts?.replace ? stack.length - 1 : stack.length, frame);
      if (opts?.replace) {
        window.history.replaceState(serializableStack(stack), "", path);
      } else {
        window.history.pushState(serializableStack(stack), "", path);
      }
      return frame;
    });

  const onlyPopFrame = (depth: number) => {
    mutStack((o) => o.toSpliced(o.length - depth, depth));
    window.history.go(-depth);
  };

  const popFrame = (depth: number = 1) =>
    untrack(() => {
      if (import.meta.env.DEV) {
        if (depth < 0) {
          console.warn("the depth to pop should not < 0, now is", depth);
        }
      }
      if (stack.length > 1) {
        const lastFrame = stack[stack.length - 1];
        const element = document.getElementById(
          lastFrame.rootId,
        )! as HTMLDialogElement;
        const createAnimation = lastFrame.animateClose ?? animateClose;
        requestAnimationFrame(() => {
          element.classList.add("animating");
          const animation = createAnimation(element);
          animation.addEventListener("finish", () => {
            element.classList.remove("animating");
            onlyPopFrame(depth);
          });
        });
      } else {
        onlyPopFrame(depth);
      }
    });

  /* createEffect(() => {
    const length = stack.length;
    console.debug("stack is changed", length, unwrap(stack));
  }); */

  createRenderEffect(() => {
    if (stack.length === 0) {
      pushFrame(window.location.pathname);
    }
  });

  createRenderEffect(() => {
    makeEventListener(window, "popstate", (event) => {
      if (event.state && stack.length !== event.state.length) {
        mutStack(event.state);
      }
    });
  });

  const onBeforeDialogMount = (element: HTMLDialogElement) => {
    onMount(() => {
      const lastFr = untrack(() => stack[stack.length - 1]);
      const createAnimation = lastFr.animateOpen ?? animateOpen;
      requestAnimationFrame(() => {
        element.showModal();
        element.classList.add("animating");
        const animation = createAnimation(element);
        animation.addEventListener("finish", () =>
          element.classList.remove("animating"),
        );
      });
    });
  };

  let reenterableAnimation: Animation | undefined;
  let origX = 0,
    origWidth = 0;

  const resetAnimation = () => {
    reenterableAnimation = undefined;
  };

  const onDialogTouchStart = (
    event: TouchEvent & { currentTarget: HTMLDialogElement },
  ) => {
    if (event.touches.length !== 1) {
      return;
    }
    const [fig0] = event.touches;
    const { x, width } = event.currentTarget.getBoundingClientRect();
    if (fig0.clientX < x - 22 || fig0.clientX > x + 22) {
      return;
    }
    origX = x;
    origWidth = width;

    const lastFr = stack[stack.length - 1];
    const createAnimation = lastFr.animateClose ?? animateClose;
    reenterableAnimation = createAnimation(event.currentTarget);
    reenterableAnimation.pause();
    reenterableAnimation.addEventListener("finish", resetAnimation);
    reenterableAnimation.addEventListener("cancel", resetAnimation);
  };

  const onDialogTouchMove = (
    event: TouchEvent & { currentTarget: HTMLDialogElement },
  ) => {
    if (event.touches.length !== 1) {
      if (reenterableAnimation) {
        reenterableAnimation.reverse();
        reenterableAnimation.play();
      }
    }

    if (!reenterableAnimation) return;

    event.preventDefault();
    event.stopPropagation();

    const [fig0] = event.touches;
    const ofsX = fig0.clientX - origX;
    const pc = ofsX / origWidth / window.devicePixelRatio;

    const { activeDuration, delay } =
      reenterableAnimation.effect!.getComputedTiming();

    const totalTime = (delay || 0) + Number(activeDuration);
    reenterableAnimation.currentTime = totalTime * pc;
  };

  const onDialogTouchEnd = (event: TouchEvent) => {
    if (!reenterableAnimation) return;

    event.preventDefault();
    event.stopPropagation();

    const { activeDuration, delay } =
      reenterableAnimation.effect!.getComputedTiming();
    const totalTime = (delay || 0) + Number(activeDuration);

    if (
      Number(reenterableAnimation.currentTime) / totalTime >
      0.1
    ) {
      reenterableAnimation.addEventListener("finish", () => {
        onlyPopFrame(1);
      });
      reenterableAnimation.play();
    } else {
      reenterableAnimation.cancel();
    }
  };

  const onDialogTouchCancel = (event: TouchEvent) => {
    if (!reenterableAnimation) return;

    event.preventDefault();
    event.stopPropagation();
    reenterableAnimation.cancel();
  };

  return (
    <NavigatorContext.Provider
      value={{
        push: pushFrame,
        pop: popFrame,
        frames: stack,
      }}
    >
      <Index each={stack}>
        {(frame, index) => {
          const currentFrame = () => {
            return {
              index,
              frame: frame(),
            };
          };

          return (
            <CurrentFrameContext.Provider value={currentFrame}>
              <Show
                when={index !== 0}
                fallback={
                  <div
                    class="StackedPage"
                    id={frame().rootId}
                    role="presentation"
                  >
                    <StaticRouter url={frame().path} {...oprops} />
                  </div>
                }
              >
                <dialog
                  ref={onBeforeDialogMount}
                  class="StackedPage"
                  onCancel={[popFrame, 1]}
                  onClick={[onDialogClick, popFrame]}
                  onTouchStart={onDialogTouchStart}
                  onTouchMove={onDialogTouchMove}
                  onTouchEnd={onDialogTouchEnd}
                  onTouchCancel={onDialogTouchCancel}
                  id={frame().rootId}
                >
                  <StaticRouter url={frame().path} {...oprops} />
                </dialog>
              </Show>
            </CurrentFrameContext.Provider>
          );
        }}
      </Index>
    </NavigatorContext.Provider>
  );
};

export default StackedRouter;
