<script setup lang="ts">
import Panzoom from "@panzoom/panzoom";
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";

type Level = "year" | "month" | "week" | "day";
type Direction = "in" | "out";

const LEVELS: Level[] = ["year", "month", "week", "day"];
const LEVEL_LABELS: Record<Level, string> = {
  year: "Year",
  month: "Month",
  week: "Week",
  day: "Day",
};

const MONTH_LABELS = [
  "Jan", "Feb", "Mar", "Apr",
  "May", "Jun", "Jul", "Aug",
  "Sep", "Oct", "Nov", "Dec",
];

const ZOOM_PRIME_STEPS = 2;
const ZOOM_ANIMATION_MS = 2000;
const VIEWPORT_PADDING = 20;

const viewportRef = ref<HTMLDivElement | null>(null);
const sceneRef = ref<HTMLDivElement | null>(null);
const panzoomRef = ref<ReturnType<typeof Panzoom> | null>(null);
const resizeObserver = ref<ResizeObserver | null>(null);

const currentLevel = ref<Level>("year");
const transitionTarget = ref<Level | null>(null);
const isAnimating = ref(false);

const selectedMonth = ref(0);
const selectedWeek = ref(0);
const selectedDay = ref(0);

const hoveredMonth = ref(0);
const hoveredWeek = ref(0);
const hoveredDay = ref(0);

const primeFocusId = ref("");

const wheelPrimeDirection = ref<"" | Direction>("");
const wheelPrimeTicks = ref(0);

let primeTimer: ReturnType<typeof setTimeout> | null = null;
let animationFrameId: number | null = null;
let animationToken = 0;

const currentLevelIndex = computed(() => LEVELS.indexOf(currentLevel.value));
const displayLevel = computed(() => transitionTarget.value ?? currentLevel.value);
const displayLevelIndex = computed(() => LEVELS.indexOf(displayLevel.value));

const canZoomIn = computed(() => currentLevelIndex.value < LEVELS.length - 1);
const canZoomOut = computed(() => currentLevelIndex.value > 0);

function getFocusId(level: Level) {
  if (level === "year") return "focus-year";
  if (level === "month") return `focus-month-${selectedMonth.value}`;
  if (level === "week") return `focus-week-${selectedWeek.value}`;
  return `focus-day-${selectedDay.value}`;
}

function findFocusElement(level: Level) {
  const scene = sceneRef.value;
  if (!scene) return null;
  const id = getFocusId(level);
  return scene.querySelector<HTMLElement>(`[data-focus-id="${id}"]`);
}

function getRectInScene(element: HTMLElement, scene: HTMLElement) {
  let x = 0;
  let y = 0;
  let node: HTMLElement | null = element;
  let reachedScene = false;

  while (node && node !== scene) {
    x += node.offsetLeft;
    y += node.offsetTop;
    node = node.offsetParent as HTMLElement | null;
  }

  if (node === scene) {
    reachedScene = true;
  }

  if (!reachedScene) {
    const sceneRect = scene.getBoundingClientRect();
    const elementRect = element.getBoundingClientRect();
    const scale = panzoomRef.value?.getScale() ?? 1;
    return {
      x: (elementRect.left - sceneRect.left) / Math.max(0.0001, scale),
      y: (elementRect.top - sceneRect.top) / Math.max(0.0001, scale),
      width: Math.max(1, elementRect.width / Math.max(0.0001, scale)),
      height: Math.max(1, elementRect.height / Math.max(0.0001, scale)),
    };
  }

  return {
    x,
    y,
    width: Math.max(1, element.offsetWidth),
    height: Math.max(1, element.offsetHeight),
  };
}

function easing(t: number) {
  return 1 - (1 - t) ** 3;
}

function stopCameraAnimation() {
  animationToken += 1;
  if (animationFrameId !== null) {
    cancelAnimationFrame(animationFrameId);
    animationFrameId = null;
  }
}

function computeTransformForElement(element: HTMLElement) {
  const viewport = viewportRef.value;
  const scene = sceneRef.value;
  if (!viewport || !scene) return null;

  const targetRect = getRectInScene(element, scene);

  const viewportWidth = Math.max(1, viewport.clientWidth);
  const viewportHeight = Math.max(1, viewport.clientHeight);
  const safeWidth = Math.max(1, viewportWidth - VIEWPORT_PADDING * 2);
  const safeHeight = Math.max(1, viewportHeight - VIEWPORT_PADDING * 2);
  const scale = Math.min(safeWidth / targetRect.width, safeHeight / targetRect.height);

  const x = VIEWPORT_PADDING + (safeWidth - targetRect.width * scale) / 2 - targetRect.x * scale;
  const y = VIEWPORT_PADDING + (safeHeight - targetRect.height * scale) / 2 - targetRect.y * scale;

  return { x, y, scale };
}

function applyTransform(transform: { x: number; y: number; scale: number }) {
  const panzoom = panzoomRef.value;
  if (!panzoom) return;
  panzoom.zoom(transform.scale, { animate: false, force: true });
  panzoom.pan(transform.x, transform.y, { animate: false, force: true });
}

async function applyCameraToElement(element: HTMLElement, animate: boolean) {
  const panzoom = panzoomRef.value;
  if (!panzoom) return;
  const target = computeTransformForElement(element);
  if (!target) return;

  if (!animate) {
    stopCameraAnimation();
    applyTransform(target);
    return;
  }

  stopCameraAnimation();
  const localToken = animationToken;
  const startPan = panzoom.getPan();
  const startScale = panzoom.getScale();
  const startAt = performance.now();

  await new Promise<void>((resolve) => {
    const step = (now: number) => {
      if (localToken !== animationToken) {
        resolve();
        return;
      }

      const elapsed = now - startAt;
      const t = Math.max(0, Math.min(1, elapsed / ZOOM_ANIMATION_MS));
      const k = easing(t);

      applyTransform({
        x: startPan.x + (target.x - startPan.x) * k,
        y: startPan.y + (target.y - startPan.y) * k,
        scale: startScale + (target.scale - startScale) * k,
      });

      if (t < 1) {
        animationFrameId = requestAnimationFrame(step);
        return;
      }

      animationFrameId = null;
      resolve();
    };

    animationFrameId = requestAnimationFrame(step);
  });
}

async function applyCameraToLevel(level: Level, animate: boolean) {
  const element = findFocusElement(level);
  if (!element) return;
  await applyCameraToElement(element, animate);
}

function startPrime(focusId: string) {
  if (primeTimer) {
    clearTimeout(primeTimer);
    primeTimer = null;
  }

  primeFocusId.value = focusId;
  primeTimer = setTimeout(() => {
    primeFocusId.value = "";
  }, 170);
}

function resetWheelPrime() {
  wheelPrimeDirection.value = "";
  wheelPrimeTicks.value = 0;
}

function nextLevel(level: Level): Level | null {
  const idx = LEVELS.indexOf(level);
  if (idx < 0 || idx >= LEVELS.length - 1) return null;
  return LEVELS[idx + 1] ?? null;
}

function prevLevel(level: Level): Level | null {
  const idx = LEVELS.indexOf(level);
  if (idx <= 0) return null;
  return LEVELS[idx - 1] ?? null;
}

function prepareZoomTarget(direction: Direction): { level: Level; focusId: string } | null {
  if (direction === "in") {
    if (currentLevel.value === "year") {
      selectedMonth.value = hoveredMonth.value;
      selectedWeek.value = 0;
      selectedDay.value = 0;
      const level = nextLevel(currentLevel.value);
      if (!level) return null;
      return { level, focusId: getFocusId(level) };
    }

    if (currentLevel.value === "month") {
      selectedWeek.value = hoveredWeek.value;
      selectedDay.value = 0;
      const level = nextLevel(currentLevel.value);
      if (!level) return null;
      return { level, focusId: getFocusId(level) };
    }

    if (currentLevel.value === "week") {
      selectedDay.value = hoveredDay.value;
      const level = nextLevel(currentLevel.value);
      if (!level) return null;
      return { level, focusId: getFocusId(level) };
    }

    return null;
  }

  const level = prevLevel(currentLevel.value);
  if (!level) return null;
  return { level, focusId: getFocusId(level) };
}

async function animateToLevel(level: Level) {
  if (isAnimating.value) return;

  isAnimating.value = true;
  transitionTarget.value = level;

  await nextTick();
  await applyCameraToLevel(level, true);

  currentLevel.value = level;
  transitionTarget.value = null;
  isAnimating.value = false;
}

async function zoom(direction: Direction) {
  if (isAnimating.value) return false;

  const target = prepareZoomTarget(direction);
  if (!target) return false;

  await animateToLevel(target.level);
  return true;
}

function onWheel(event: WheelEvent) {
  if (isAnimating.value) return;

  const direction: Direction = event.deltaY < 0 ? "in" : "out";
  const target = prepareZoomTarget(direction);
  if (!target) return;

  if (wheelPrimeDirection.value !== direction) {
    wheelPrimeDirection.value = direction;
    wheelPrimeTicks.value = 0;
  }

  if (wheelPrimeTicks.value < ZOOM_PRIME_STEPS) {
    wheelPrimeTicks.value += 1;
    startPrime(target.focusId);
    return;
  }

  resetWheelPrime();
  void animateToLevel(target.level);
}

async function onSliderInput(event: Event) {
  const target = event.target as HTMLInputElement | null;
  if (!target || isAnimating.value) return;

  resetWheelPrime();

  const targetIndex = Number(target.value);
  const safeTargetIndex = Math.max(0, Math.min(LEVELS.length - 1, targetIndex));

  while (!isAnimating.value && currentLevelIndex.value < safeTargetIndex) {
    const moved = await zoom("in");
    if (!moved) break;
  }

  while (!isAnimating.value && currentLevelIndex.value > safeTargetIndex) {
    const moved = await zoom("out");
    if (!moved) break;
  }
}

function isPrime(id: string) {
  return primeFocusId.value === id;
}

function isMonthSelected(index: number) {
  return selectedMonth.value === index;
}

function isWeekSelected(index: number) {
  return selectedWeek.value === index;
}

function isDaySelected(index: number) {
  return selectedDay.value === index;
}

function showMonthContent(index: number) {
  return isMonthSelected(index) && currentLevel.value !== "year";
}

function showWeekContent(index: number) {
  return isWeekSelected(index) && (currentLevel.value === "week" || currentLevel.value === "day");
}

function showDayContent(index: number) {
  return isDaySelected(index) && currentLevel.value === "day";
}

onMounted(async () => {
  await nextTick();

  if (sceneRef.value) {
    panzoomRef.value = Panzoom(sceneRef.value, {
      animate: false,
      maxScale: 24,
      minScale: 0.08,
      disablePan: true,
      origin: "0 0",
    });
  }

  applyCameraToLevel("year", false);

  resizeObserver.value = new ResizeObserver(() => {
    applyCameraToLevel(displayLevel.value, false);
  });

  if (viewportRef.value) {
    resizeObserver.value.observe(viewportRef.value);
  }
});

onBeforeUnmount(() => {
  if (primeTimer) clearTimeout(primeTimer);
  stopCameraAnimation();
  resizeObserver.value?.disconnect();
  panzoomRef.value?.destroy();
  panzoomRef.value = null;
});
</script>

<template>
  <section class="calendar-lab-root">
    <header class="calendar-lab-toolbar">
      <p class="calendar-lab-level-text">
        Current level: {{ LEVEL_LABELS[currentLevel] }}
      </p>

      <div class="calendar-zoom-inline" @click.stop>
        <input
          class="calendar-zoom-slider"
          type="range"
          min="0"
          max="3"
          step="1"
          :value="displayLevelIndex"
          aria-label="Calendar zoom level"
          @input="onSliderInput"
        >
        <div class="calendar-zoom-marks" aria-hidden="true">
          <span
            v-for="index in 4"
            :key="`calendar-lab-zoom-mark-${index}`"
            class="calendar-zoom-mark"
            :class="displayLevelIndex === index - 1 ? 'calendar-zoom-mark-active' : ''"
          />
        </div>
      </div>
    </header>

    <div
      ref="viewportRef"
      class="calendar-lab-viewport"
      :class="canZoomIn ? 'cursor-zoom-in' : 'cursor-zoom-out'"
      @wheel.prevent="onWheel"
    >
      <div ref="sceneRef" class="calendar-lab-scene">
        <article class="calendar-year" data-focus-id="focus-year">
          <div class="calendar-year-grid">
            <div
              v-for="(label, monthIndex) in MONTH_LABELS"
              :key="`month-${label}`"
              class="calendar-month-card"
              :class="[
                isMonthSelected(monthIndex) ? 'calendar-month-card-selected' : '',
                currentLevel === 'year' && hoveredMonth === monthIndex ? 'calendar-hover-target' : '',
                isPrime(`focus-month-${monthIndex}`) ? 'calendar-prime-target' : '',
              ]"
              :data-focus-id="`focus-month-${monthIndex}`"
              @mouseenter="currentLevel === 'year' ? (hoveredMonth = monthIndex) : undefined"
            >
              <p class="calendar-card-label">{{ label }}</p>

              <div v-if="showMonthContent(monthIndex)" class="calendar-week-grid-wrap">
                <div class="calendar-week-grid">
                  <div
                    v-for="weekIndex in 6"
                    :key="`week-${weekIndex - 1}`"
                    class="calendar-week-card"
                    :class="[
                      isWeekSelected(weekIndex - 1) ? 'calendar-week-card-selected' : '',
                      currentLevel === 'month' && hoveredWeek === weekIndex - 1 ? 'calendar-hover-target' : '',
                      isPrime(`focus-week-${weekIndex - 1}`) ? 'calendar-prime-target' : '',
                    ]"
                    :data-focus-id="`focus-week-${weekIndex - 1}`"
                    @mouseenter="currentLevel === 'month' ? (hoveredWeek = weekIndex - 1) : undefined"
                  >
                    <p class="calendar-card-label">Week {{ weekIndex }}</p>

                    <div v-if="showWeekContent(weekIndex - 1)" class="calendar-day-grid-wrap">
                      <div class="calendar-day-grid">
                        <div
                          v-for="dayIndex in 7"
                          :key="`day-${dayIndex - 1}`"
                          class="calendar-day-card"
                          :class="[
                            isDaySelected(dayIndex - 1) ? 'calendar-day-card-selected' : '',
                            currentLevel === 'week' && hoveredDay === dayIndex - 1 ? 'calendar-hover-target' : '',
                            isPrime(`focus-day-${dayIndex - 1}`) ? 'calendar-prime-target' : '',
                          ]"
                          :data-focus-id="`focus-day-${dayIndex - 1}`"
                          @mouseenter="currentLevel === 'week' ? (hoveredDay = dayIndex - 1) : undefined"
                        >
                          <p class="calendar-card-label">Day {{ dayIndex }}</p>

                          <div v-if="showDayContent(dayIndex - 1)" class="calendar-slot-grid-wrap">
                            <div class="calendar-slot-grid">
                              <span
                                v-for="slot in 12"
                                :key="`slot-${slot}`"
                                class="calendar-slot"
                              />
                            </div>
                          </div>
                        </div>
                      </div>
                    </div>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </article>
      </div>
    </div>
  </section>
</template>

<style scoped>
.calendar-lab-root {
  height: calc(100dvh - 2.5rem);
  min-height: 620px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 10px;
}

.calendar-lab-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}

.calendar-lab-level-text {
  font-size: 0.78rem;
  color: color-mix(in oklab, var(--color-base-content) 72%, transparent);
}

.calendar-lab-viewport {
  position: relative;
  flex: 1;
  min-height: 0;
  border-radius: 14px;
  border: 1px solid color-mix(in oklab, var(--color-base-content) 18%, transparent);
  background:
    radial-gradient(circle at 15% 15%, color-mix(in oklab, var(--color-base-content) 6%, transparent), transparent 40%),
    color-mix(in oklab, var(--color-base-100) 94%, transparent);
  overflow: hidden;
}

.calendar-lab-scene {
  position: relative;
  width: 1400px;
  height: 900px;
  will-change: transform;
}

.calendar-year {
  position: absolute;
  left: 80px;
  top: 50px;
  width: 1240px;
  height: 760px;
  border-radius: 14px;
  border: 1px solid color-mix(in oklab, var(--color-base-content) 24%, transparent);
  background: color-mix(in oklab, var(--color-base-100) 96%, transparent);
  padding: 14px;
}

.calendar-year-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  grid-template-rows: repeat(3, minmax(0, 1fr));
  gap: 10px;
  width: 100%;
  height: 100%;
}

.calendar-month-card,
.calendar-week-card,
.calendar-day-card {
  border-radius: 12px;
  border: 1px solid color-mix(in oklab, var(--color-base-content) 18%, transparent);
  background: color-mix(in oklab, var(--color-base-200) 72%, transparent);
  transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
  min-width: 0;
  min-height: 0;
  padding: 8px;
}

.calendar-month-card-selected,
.calendar-week-card-selected,
.calendar-day-card-selected {
  border-color: color-mix(in oklab, var(--color-primary) 70%, transparent);
}

.calendar-card-label {
  font-size: 0.72rem;
  font-weight: 600;
  color: color-mix(in oklab, var(--color-base-content) 88%, transparent);
}

.calendar-week-grid-wrap,
.calendar-day-grid-wrap,
.calendar-slot-grid-wrap {
  width: 100%;
  height: calc(100% - 20px);
  margin-top: 6px;
}

.calendar-week-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  grid-template-rows: repeat(2, minmax(0, 1fr));
  gap: 8px;
  width: 100%;
  height: 100%;
}

.calendar-day-grid {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  gap: 6px;
  width: 100%;
  height: 100%;
}

.calendar-slot-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  grid-template-rows: repeat(3, minmax(0, 1fr));
  gap: 5px;
  width: 100%;
  height: 100%;
}

.calendar-slot {
  border-radius: 8px;
  border: 1px solid color-mix(in oklab, var(--color-base-content) 16%, transparent);
  background: color-mix(in oklab, var(--color-base-100) 88%, transparent);
}

.calendar-hover-target {
  border-color: color-mix(in oklab, var(--color-primary) 70%, transparent);
  box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 36%, transparent) inset;
}

.calendar-prime-target {
  transform: scale(1.05);
}

.calendar-zoom-inline {
  position: relative;
  display: flex;
  align-items: center;
  width: 128px;
  height: 22px;
  padding: 0 10px;
}

.calendar-zoom-slider {
  width: 100%;
  height: 18px;
  margin: 0;
  background: transparent;
  -webkit-appearance: none;
  appearance: none;
  cursor: pointer;
}

.calendar-zoom-slider:focus-visible {
  outline: none;
}

.calendar-zoom-slider::-webkit-slider-runnable-track {
  height: 2px;
  border-radius: 999px;
  background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
}

.calendar-zoom-slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 10px;
  height: 10px;
  margin-top: -4px;
  border-radius: 999px;
  background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
  border: 0;
}

.calendar-zoom-slider::-moz-range-track {
  height: 2px;
  border-radius: 999px;
  background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
}

.calendar-zoom-slider::-moz-range-progress {
  height: 2px;
  border-radius: 999px;
  background: transparent;
}

.calendar-zoom-slider::-moz-range-thumb {
  width: 10px;
  height: 10px;
  border: 0;
  border-radius: 999px;
  background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
}

.calendar-zoom-marks {
  position: absolute;
  inset: 0 10px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  pointer-events: none;
}

.calendar-zoom-mark {
  width: 4px;
  height: 4px;
  border-radius: 999px;
  background: color-mix(in oklab, var(--color-base-content) 35%, transparent);
}

.calendar-zoom-mark-active {
  background: color-mix(in oklab, var(--color-base-content) 85%, transparent);
}
</style>
