<script setup>
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore'
import CalendarHeader from '@/components/CalendarHeader.vue'
import CalendarWeek from '@/components/CalendarWeek.vue'
import Jogwheel from '@/components/Jogwheel.vue'
import SettingsDialog from '@/components/SettingsDialog.vue'
import {
  getLocalizedMonthName,
  monthAbbr,
  lunarPhaseSymbol,
  pad,
  daysInclusive,
  addDaysStr,
  formatDateRange,
  formatTodayString,
  getOccurrenceIndex,
  getVirtualOccurrenceEndDate,
  getISOWeek,
  MIN_YEAR,
  MAX_YEAR,
} from '@/utils/date'
import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date'
import { addDays, differenceInCalendarDays, differenceInWeeks } from 'date-fns'
import { getHolidayForDate } from '@/utils/holidays'

const calendarStore = useCalendarStore()
const viewport = ref(null)
const settingsDialog = ref(null)

const emit = defineEmits(['create-event', 'edit-event'])

function createEventFromSelection() {
  if (!selection.value.startDate || selection.value.dayCount === 0) return null

  return {
    startDate: selection.value.startDate,
    dayCount: selection.value.dayCount,
    endDate: addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
  }
}

const scrollTop = ref(0)
const viewportHeight = ref(600)
const rowHeight = ref(64)
const baseDate = computed(() => new Date(1970, 0, 4 + calendarStore.config.first_day))

const selection = ref({ startDate: null, dayCount: 0 })
const isDragging = ref(false)
const dragAnchor = ref(null)

const DOUBLE_TAP_DELAY = 300
const pendingTap = ref({ date: null, time: 0, type: null })
const suppressMouseUntil = ref(0)

function normalizeDate(val) {
  if (typeof val === 'string') return val
  if (val && typeof val === 'object') {
    if (val.date) return String(val.date)
    if (val.startDate) return String(val.startDate)
  }
  return String(val)
}

function registerTap(rawDate, type) {
  const dateStr = normalizeDate(rawDate)
  const now = Date.now()
  const prev = pendingTap.value
  const delta = now - prev.time
  const isDouble =
    prev.date === dateStr && prev.type === type && delta <= DOUBLE_TAP_DELAY && delta >= 35
  if (isDouble) {
    pendingTap.value = { date: null, time: 0, type: null }
    return true
  }
  pendingTap.value = { date: dateStr, time: now, type }
  return false
}

const minVirtualWeek = computed(() => {
  const date = new Date(MIN_YEAR, 0, 1)
  const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
  const firstDayOfWeek = addDays(date, -dayOffset)
  return differenceInWeeks(firstDayOfWeek, baseDate.value)
})

const maxVirtualWeek = computed(() => {
  const date = new Date(MAX_YEAR, 11, 31)
  const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
  const firstDayOfWeek = addDays(date, -dayOffset)
  return differenceInWeeks(firstDayOfWeek, baseDate.value)
})

const totalVirtualWeeks = computed(() => {
  return maxVirtualWeek.value - minVirtualWeek.value + 1
})

const initialScrollTop = computed(() => {
  const nowDate = new Date(calendarStore.now)
  const targetWeekIndex = getWeekIndex(nowDate) - 3
  return (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
})

const selectedDateRange = computed(() => {
  if (!selection.value.start || !selection.value.end) return ''
  return formatDateRange(
    fromLocalString(selection.value.start),
    fromLocalString(selection.value.end),
  )
})

const todayString = computed(() => {
  const d = new Date(calendarStore.now)
  return formatTodayString(d)
})

// PERFORMANCE: Maintain a manual cache of computed weeks instead of relying on
// deep reactive tracking of every event & day object. We rebuild lazily when
// (a) scrolling changes the needed range or (b) eventsMutation counter bumps.
const visibleWeeks = ref([])
let lastScrollRange = { startVW: null, endVW: null }
let pendingRebuild = false

function scheduleRebuild(reason) {
  if (pendingRebuild) return
  pendingRebuild = true
  // Use requestIdleCallback when available, else fallback to rAF
  const cb = () => {
    pendingRebuild = false
    rebuildVisibleWeeks(reason)
  }
  if ('requestIdleCallback' in window) {
    requestIdleCallback(cb, { timeout: 120 })
  } else {
    requestAnimationFrame(cb)
  }
}

function rebuildVisibleWeeks(reason) {
  const buffer = 10
  const startIdx = Math.floor((scrollTop.value - buffer * rowHeight.value) / rowHeight.value)
  const endIdx = Math.ceil(
    (scrollTop.value + viewportHeight.value + buffer * rowHeight.value) / rowHeight.value,
  )
  const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value)
  const endVW = Math.min(maxVirtualWeek.value, endIdx + minVirtualWeek.value)
  if (
    reason === 'scroll' &&
    lastScrollRange.startVW === startVW &&
    lastScrollRange.endVW === endVW &&
    visibleWeeks.value.length
  ) {
    return
  }
  const weeks = []
  for (let vw = startVW; vw <= endVW; vw++) weeks.push(createWeek(vw))
  visibleWeeks.value = weeks
  lastScrollRange = { startVW, endVW }
}

const contentHeight = computed(() => {
  return totalVirtualWeeks.value * rowHeight.value
})

function computeRowHeight() {
  const el = document.createElement('div')
  el.style.position = 'absolute'
  el.style.visibility = 'hidden'
  el.style.height = 'var(--cell-h)'
  document.body.appendChild(el)
  const h = el.getBoundingClientRect().height || 64
  el.remove()
  rowHeight.value = Math.round(h)
  return rowHeight.value
}

function getWeekIndex(date) {
  const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
  const firstDayOfWeek = addDays(date, -dayOffset)
  return differenceInWeeks(firstDayOfWeek, baseDate.value)
}

function getFirstDayForVirtualWeek(virtualWeek) {
  return addDays(baseDate.value, virtualWeek * 7)
}

function createWeek(virtualWeek) {
  const firstDay = getFirstDayForVirtualWeek(virtualWeek)
  const isoAnchor = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7)
  const weekNumber = getISOWeek(isoAnchor)
  const days = []
  let cur = new Date(firstDay)
  let hasFirst = false
  let monthToLabel = null
  let labelYear = null

  const repeatingBases = []
  if (calendarStore.events) {
    for (const ev of calendarStore.events.values()) {
      if (ev.isRepeating) repeatingBases.push(ev)
    }
  }

  for (let i = 0; i < 7; i++) {
    const dateStr = toLocalString(cur, DEFAULT_TZ)
    const storedEvents = []

    for (const ev of calendarStore.events.values()) {
      if (!ev.isRepeating && dateStr >= ev.startDate && dateStr <= ev.endDate) {
        storedEvents.push(ev)
      }
    }
    const dayEvents = [...storedEvents]
    // Expand repeating events into per-day occurrences (including virtual spans) when they cover this date.
    for (const base of repeatingBases) {
      // Base event's original span: include it directly as occurrence index 0.
      if (dateStr >= base.startDate && dateStr <= base.endDate) {
        dayEvents.push({
          ...base,
          _recurrenceIndex: 0,
          _baseId: base.id,
        })
        continue
      }

      const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
      const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
      const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
      const currentDate = fromLocalString(dateStr, DEFAULT_TZ)

      let occurrenceFound = false

      // Walk backwards within the base span to locate a matching virtual occurrence start.
      for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) {
        const candidateStart = addDays(currentDate, -offset)
        const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)

        const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
        if (occurrenceIndex !== null) {
          const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr, DEFAULT_TZ)

          if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) {
            const virtualId = base.id + '_v_' + candidateStartStr
            const alreadyExists = dayEvents.some((ev) => ev.id === virtualId)

            if (!alreadyExists) {
              dayEvents.push({
                ...base,
                id: virtualId,
                startDate: candidateStartStr,
                endDate: virtualEndDate,
                _recurrenceIndex: occurrenceIndex,
                _baseId: base.id,
              })
            }
            occurrenceFound = true
          }
        }
      }
    }
    const dow = cur.getDay()
    const isFirst = cur.getDate() === 1

    if (isFirst) {
      hasFirst = true
      monthToLabel = cur.getMonth()
      labelYear = cur.getFullYear()
    }

    let displayText = String(cur.getDate())
    if (isFirst) {
      if (cur.getMonth() === 0) {
        displayText = cur.getFullYear()
      } else {
        displayText = monthAbbr[cur.getMonth()].slice(0, 3).toUpperCase()
      }
    }

    let holiday = null
    if (calendarStore.config.holidays.enabled) {
      calendarStore._ensureHolidaysInitialized?.()
      holiday = getHolidayForDate(dateStr)
    }

    days.push({
      date: dateStr,
      dayOfMonth: cur.getDate(),
      displayText,
      monthClass: monthAbbr[cur.getMonth()],
      isToday: dateStr === calendarStore.today,
      isWeekend: calendarStore.weekend[dow],
      isFirstDay: isFirst,
      lunarPhase: lunarPhaseSymbol(cur),
      holiday: holiday,
      isHoliday: holiday !== null,
      isSelected:
        selection.value.startDate &&
        selection.value.dayCount > 0 &&
        dateStr >= selection.value.startDate &&
        dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
      events: dayEvents,
    })
    cur = addDays(cur, 1)
  }

  let monthLabel = null
  if (hasFirst && monthToLabel !== null) {
    if (labelYear && labelYear <= MAX_YEAR) {
      let weeksSpan = 0
      const d = addDays(cur, -1)

      for (let i = 0; i < 6; i++) {
        const probe = addDays(cur, -1 + i * 7)
        d.setTime(probe.getTime())
        if (d.getMonth() === monthToLabel) weeksSpan++
      }

      const remainingWeeks = Math.max(1, maxVirtualWeek.value - virtualWeek + 1)
      weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks))

      const year = String(labelYear).slice(-2)
      monthLabel = {
        text: `${getLocalizedMonthName(monthToLabel)} '${year}`,
        month: monthToLabel,
        weeksSpan: weeksSpan,
        height: weeksSpan * rowHeight.value,
      }
    }
  }

  return {
    virtualWeek,
    weekNumber: pad(weekNumber),
    days,
    monthLabel,
    top: (virtualWeek - minVirtualWeek.value) * rowHeight.value,
  }
}

function goToToday() {
  const top = addDays(new Date(calendarStore.now), -21)
  const targetWeekIndex = getWeekIndex(top)
  scrollTop.value = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
  if (viewport.value) {
    viewport.value.scrollTop = scrollTop.value
  }
}

function clearSelection() {
  selection.value = { startDate: null, dayCount: 0 }
}

function startDrag(dateStr) {
  dateStr = normalizeDate(dateStr)
  if (calendarStore.config.select_days === 0) return
  isDragging.value = true
  dragAnchor.value = dateStr
  selection.value = { startDate: dateStr, dayCount: 1 }
  addGlobalTouchListeners()
}

function updateDrag(dateStr) {
  if (!isDragging.value) return
  const { startDate, dayCount } = calculateSelection(dragAnchor.value, dateStr)
  selection.value = { startDate, dayCount }
}

function endDrag(dateStr) {
  if (!isDragging.value) return
  isDragging.value = false
  const { startDate, dayCount } = calculateSelection(dragAnchor.value, dateStr)
  selection.value = { startDate, dayCount }
}

function finalizeDragAndCreate() {
  if (!isDragging.value) return
  isDragging.value = false
  const eventData = createEventFromSelection()
  if (eventData) {
    clearSelection()
    emit('create-event', eventData)
  }
  removeGlobalTouchListeners()
}

function getDateUnderPoint(x, y) {
  const el = document.elementFromPoint(x, y)
  let cur = el
  while (cur) {
    if (cur.dataset && cur.dataset.date) return cur.dataset.date
    cur = cur.parentElement
  }
  return getDateFromCoordinates(x, y)
}

function onGlobalTouchMove(e) {
  if (!isDragging.value) return
  const t = e.touches && e.touches[0]
  if (!t) return
  e.preventDefault()
  const dateStr = getDateUnderPoint(t.clientX, t.clientY)
  if (dateStr) updateDrag(dateStr)
}

function onGlobalTouchEnd(e) {
  if (!isDragging.value) {
    removeGlobalTouchListeners()
    return
  }
  const t = (e.changedTouches && e.changedTouches[0]) || (e.touches && e.touches[0])
  if (t) {
    const dateStr = getDateUnderPoint(t.clientX, t.clientY)
    if (dateStr) {
      const { startDate, dayCount } = calculateSelection(dragAnchor.value, dateStr)
      selection.value = { startDate, dayCount }
    }
  }
  finalizeDragAndCreate()
}

function addGlobalTouchListeners() {
  window.addEventListener('touchmove', onGlobalTouchMove, { passive: false })
  window.addEventListener('touchend', onGlobalTouchEnd, { passive: false })
  window.addEventListener('touchcancel', onGlobalTouchEnd, { passive: false })
}

function removeGlobalTouchListeners() {
  window.removeEventListener('touchmove', onGlobalTouchMove)
  window.removeEventListener('touchend', onGlobalTouchEnd)
  window.removeEventListener('touchcancel', onGlobalTouchEnd)
}

// Fallback hit-test if elementFromPoint doesn't find a day cell (e.g., moving between rows).
function getDateFromCoordinates(clientX, clientY) {
  if (!viewport.value) return null
  const vpRect = viewport.value.getBoundingClientRect()
  const yOffset = clientY - vpRect.top + viewport.value.scrollTop
  if (yOffset < 0) return null
  const rowIndex = Math.floor(yOffset / rowHeight.value)
  const virtualWeek = minVirtualWeek.value + rowIndex
  if (virtualWeek < minVirtualWeek.value || virtualWeek > maxVirtualWeek.value) return null
  const sampleWeek = viewport.value.querySelector('.week-row')
  if (!sampleWeek) return null
  const labelEl = sampleWeek.querySelector('.week-label')
  const jogwheelWidth = 48
  const wrRect = sampleWeek.getBoundingClientRect()
  const labelRight = labelEl ? labelEl.getBoundingClientRect().right : wrRect.left
  const daysAreaRight = wrRect.right - jogwheelWidth
  const daysWidth = daysAreaRight - labelRight
  if (clientX < labelRight || clientX > daysAreaRight) return null
  const col = Math.min(6, Math.max(0, Math.floor(((clientX - labelRight) / daysWidth) * 7)))
  const firstDay = getFirstDayForVirtualWeek(virtualWeek)
  const targetDate = addDays(firstDay, col)
  return toLocalString(targetDate, DEFAULT_TZ)
}

function calculateSelection(anchorStr, otherStr) {
  const limit = calendarStore.config.select_days
  const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ)
  const otherDate = fromLocalString(otherStr, DEFAULT_TZ)
  const forward = otherDate >= anchorDate
  const span = daysInclusive(anchorStr, otherStr)

  if (span <= limit) {
    const startDate = forward ? anchorStr : otherStr
    return { startDate, dayCount: span }
  }

  if (forward) {
    return { startDate: anchorStr, dayCount: limit }
  } else {
    const startDate = addDaysStr(anchorStr, -(limit - 1), DEFAULT_TZ)
    return { startDate, dayCount: limit }
  }
}

const onScroll = () => {
  if (viewport.value) scrollTop.value = viewport.value.scrollTop
  scheduleRebuild('scroll')
}

const handleJogwheelScrollTo = (newScrollTop) => {
  if (viewport.value) {
    viewport.value.scrollTop = newScrollTop
  }
}

onMounted(() => {
  computeRowHeight()
  calendarStore.updateCurrentDate()

  if (viewport.value) {
    viewportHeight.value = viewport.value.clientHeight
    viewport.value.scrollTop = initialScrollTop.value
    viewport.value.addEventListener('scroll', onScroll)
  }

  const timer = setInterval(() => {
    calendarStore.updateCurrentDate()
  }, 60000)

  // Initial build after mount & measurement
  scheduleRebuild('init')

  onBeforeUnmount(() => {
    clearInterval(timer)
  })
})

onBeforeUnmount(() => {
  if (viewport.value) {
    viewport.value.removeEventListener('scroll', onScroll)
  }
})

const handleDayMouseDown = (d) => {
  d = normalizeDate(d)
  if (Date.now() < suppressMouseUntil.value) return
  if (registerTap(d, 'mouse')) startDrag(d)
}
const handleDayMouseEnter = (d) => updateDrag(normalizeDate(d))
const handleDayMouseUp = (d) => {
  d = normalizeDate(d)
  if (Date.now() < suppressMouseUntil.value && !isDragging.value) return
  if (!isDragging.value) return
  endDrag(d)
  const ev = createEventFromSelection()
  if (ev) {
    clearSelection()
    emit('create-event', ev)
  }
}
const handleDayTouchStart = (d) => {
  d = normalizeDate(d)
  suppressMouseUntil.value = Date.now() + 800
  if (registerTap(d, 'touch')) startDrag(d)
}

const handleEventClick = (payload) => {
  emit('edit-event', payload)
}

const handleHeaderYearChange = ({ scrollTop: st }) => {
  const maxScroll = contentHeight.value - viewportHeight.value
  const clamped = Math.max(0, Math.min(st, isFinite(maxScroll) ? maxScroll : st))
  scrollTop.value = clamped
  viewport.value && (viewport.value.scrollTop = clamped)
}

function openSettings() {
  settingsDialog.value?.open()
}
// Keep roughly same visible date when first_day setting changes.
watch(
  () => calendarStore.config.first_day,
  () => {
    const currentTopVW = Math.floor(scrollTop.value / rowHeight.value) + minVirtualWeek.value
    const currentTopDate = getFirstDayForVirtualWeek(currentTopVW)
    requestAnimationFrame(() => {
      const newTopWeekIndex = getWeekIndex(currentTopDate)
      const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value
      scrollTop.value = newScroll
      if (viewport.value) viewport.value.scrollTop = newScroll
      scheduleRebuild('first-day-change')
    })
  },
)

// Watch lightweight mutation counter only (not deep events map) and rebuild lazily
watch(
  () => calendarStore.events,
  () => {
    scheduleRebuild('events')
  },
  { deep: true },
)

// Rebuild if viewport height changes (e.g., resize)
window.addEventListener('resize', () => {
  if (viewport.value) viewportHeight.value = viewport.value.clientHeight
  scheduleRebuild('resize')
})
</script>

<template>
  <div class="wrap">
    <header>
      <h1>Calendar</h1>
      <div class="header-controls">
        <div class="today-date" @click="goToToday">{{ todayString }}</div>
        <button
          type="button"
          class="settings-btn"
          @click="openSettings"
          aria-label="Open settings"
          title="Settings"
        >
          ⚙
        </button>
      </div>
    </header>
    <CalendarHeader
      :scroll-top="scrollTop"
      :row-height="rowHeight"
      :min-virtual-week="minVirtualWeek"
      @year-change="handleHeaderYearChange"
    />
    <div class="calendar-container">
      <div class="calendar-viewport" ref="viewport">
        <div class="calendar-content" :style="{ height: contentHeight + 'px' }">
          <CalendarWeek
            v-for="week in visibleWeeks"
            :key="week.virtualWeek"
            :week="week"
            :dragging="isDragging"
            :style="{ top: week.top + 'px' }"
            @day-mousedown="handleDayMouseDown"
            @day-mouseenter="handleDayMouseEnter"
            @day-mouseup="handleDayMouseUp"
            @day-touchstart="handleDayTouchStart"
            @event-click="handleEventClick"
          />
          <!-- Month labels positioned absolutely -->
          <div
            v-for="week in visibleWeeks"
            :key="`month-${week.virtualWeek}`"
            v-show="week.monthLabel"
            class="month-name-label"
            :style="{
              top: week.top + 'px',
              height: week.monthLabel?.height + 'px',
            }"
          >
            <span>{{ week.monthLabel?.text }}</span>
          </div>
        </div>
      </div>
      <!-- Jogwheel as sibling to calendar-viewport -->
      <Jogwheel
        :total-virtual-weeks="totalVirtualWeeks"
        :row-height="rowHeight"
        :viewport-height="viewportHeight"
        :scroll-top="scrollTop"
        @scroll-to="handleJogwheelScrollTo"
      />
    </div>
    <SettingsDialog ref="settingsDialog" />
  </div>
</template>

<style scoped>
.wrap {
  height: 100vh;
  display: flex;
  flex-direction: column;
}

header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

header h1 {
  margin: 0;
  padding: 0;
}
.header-controls {
  display: flex;
  gap: 1rem;
  align-items: center;
}

.settings-btn {
  background: transparent;
  border: none;
  color: var(--muted);
  padding: 0;
  margin: 0;
  margin-right: 0.6rem;
  cursor: pointer;
  font-size: 1.5rem;
  line-height: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  outline: none;
}
.settings-btn:hover {
  color: var(--strong);
}
.settings-btn:focus-visible {
  /* Keep visual accessibility without background */
  outline: 2px solid var(--selected);
  outline-offset: 2px;
}
.settings-btn:active {
  transform: scale(0.88);
}

.today-date {
  cursor: pointer;
  padding: 0.5rem;
  background: var(--today-btn-bg);
  color: var(--today-btn-text);
  border-radius: 4px;
  white-space: pre-line;
  text-align: center;
  font-size: 0.9rem;
}

.today-date:hover {
  background: var(--today-btn-hover-bg);
}

.calendar-container {
  flex: 1;
  display: flex;
  position: relative;
  /* Prevent text selection in calendar */
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
}

.calendar-viewport {
  flex: 1;
  overflow-y: auto;
  overflow-x: hidden;
  position: relative;
}

.calendar-content {
  position: relative;
  width: 100%;
}

.month-name-label {
  position: absolute;
  right: 0;
  width: 3rem; /* Match jogwheel width */
  font-size: 2em;
  font-weight: 700;
  color: var(--muted);
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  z-index: 15;
  overflow: visible;
}

.month-name-label > span {
  display: inline-block;
  white-space: nowrap;
  writing-mode: vertical-rl;
  text-orientation: mixed;
  transform: rotate(180deg);
  transform-origin: center;
}
</style>
