import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import FullCalendar from '@fullcalendar/react'
import type { DatesSetArg, EventContentArg, EventInput, EventMountArg } from '@fullcalendar/core'
import type { EventClickArg, EventHoveringArg } from '@fullcalendar/core'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
import ruLocale from '@fullcalendar/core/locales/ru'
import { ChevronLeft, ChevronRight, Lock, Pencil } from 'lucide-react'
import type { ConferenceOut, OccurrenceOut } from '@/api/conferences'
import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard'

/*
 * Стабильные (модульные, не пересоздаваемые на каждый рендер) значения для
 * пропсов FullCalendar. `@fullcalendar/react` при любом изменении identity
 * пропса-опции (массива/объекта/функции) дёргает внутренний `resetOptions`,
 * который заново вызывает `datesSet` — если тот, в свою очередь, меняет
 * состояние компонента без проверки «а изменилось ли значение», получается
 * бесконечный цикл setState → рендер → новые identity пропсов → resetOptions
 * → datesSet → setState (баг гейта, 2026-07-16: /calendar рендерился пустым
 * экраном с «Maximum update depth exceeded» в консоли). Поэтому: массивы
 * `plugins`/`hiddenDays` и функция `renderEventContent` — константы модуля
 * (не зависят от пропсов/состояния), а `events`/колбэки, зависящие от
 * пропсов, — обёрнуты в `useMemo`/`useCallback` ниже.
 */
const CALENDAR_PLUGINS = [dayGridPlugin, interactionPlugin]
const HIDDEN_DAYS_BUSINESS_WEEK: number[] = [0, 6]
const HIDDEN_DAYS_NONE: number[] = []

/**
 * Хранилище отписок focusin/focusout по DOM-элементу чипа:
 * ховер-карточка по клавиатурному фокусу) — элементы событий FullCalendar
 * рендерятся вне React-дерева, поэтому слушатели вешаются нативно в
 * `eventDidMount`/снимаются в `eventWillUnmount`; `WeakMap` не мешает сборке
 * мусора при повторном рендере событий.
 */
const eventFocusCleanups = new WeakMap<HTMLElement, () => void>()

/**
 * Актуальный колбэк редактирования из последнего рендера
 * `ConferenceCalendar`. `renderEventContent` обязан оставаться одной и той же
 * функцией на всё время жизни модуля (см. комментарий выше про
 * resetOptions/бесконечный цикл), поэтому не может напрямую замкнуть пропс
 * компонента — вместо этого читает его отсюда, а компонент синхронизирует
 * переменную эффектом при каждом изменении пропса.
 */
let latestOnEditClick: (conference: ConferenceOut) => void = () => {}

export type CalendarViewMode = 'dayGridWeek' | 'dayGridMonth'

interface ConferenceCalendarProps {
  view: CalendarViewMode
  onViewChange: (view: CalendarViewMode) => void
  occurrences: OccurrenceOut[]
  isLoading: boolean
  /** Владельческие конференции текущего пользователя (`/conferences/my`, только свои — `is_owner`) — определяет, показывать ли карандашик на чипе. */
  myConferenceById: Map<string, ConferenceOut>
  /** Сообщает видимый диапазон сетки (UTC ISO) — используется для запроса вхождений. */
  onRangeChange: (fromIso: string, toIso: string) => void
  onOccurrenceClick: (occurrence: OccurrenceOut) => void
  onEditClick: (conference: ConferenceOut) => void
}

/**
 * Не зависит от пропсов/состояния компонента — модульная константа-функция
 * (см. комментарий выше про стабильность identity). В чипе — только название
 * (обрезается многоточием по цепочке min-width:0/overflow:hidden ниже по
 * дереву, см. calendar.css) и, у владельца, карандашик редактирования; время/
 * статус/номер — только в информационном окне (`ConferenceOccurrenceDialog`).
 */
function renderEventContent(arg: EventContentArg) {
  const occ = arg.event.extendedProps.occurrence as OccurrenceOut
  const ownedConference = arg.event.extendedProps.ownedConference as ConferenceOut | undefined
  return (
    <div className="chip-row">
      <span className="chip-title">
        {occ.is_closed && <Lock className="chip-lock" aria-hidden="true" />}
        <span className="chip-title-text">{arg.event.title}</span>
      </span>
      {ownedConference && (
        <button
          type="button"
          className="chip-edit"
          aria-label="Редактировать конференцию"
          onClick={(e) => {
            // Иначе клик долетит и до обработчика чипа (eventClick) — тот
            // открыл бы информационное окно вместо формы редактирования.
            e.stopPropagation()
            latestOnEditClick(ownedConference)
          }}
        >
          <Pencil aria-hidden="true" />
        </button>
      )}
    </div>
  )
}

/** «г.» — сноска Intl.DateTimeFormat('ru-RU', ...) после года при формате year+month(+day); в подписи периода не нужна. */
const YEAR_SUFFIX_RE = /\s*г\.(?=\s|$)/g

/**
 * Подписи дней в самой сетке, из которых надо убрать «г.» (см.
 * `useStripYearSuffix`): месячный вид — номер дня в ячейке, недельный —
 * подпись колонки дня.
 */
const DAY_LABEL_SELECTOR = '.fc-daygrid-day-number, .fc-col-header-cell-cushion'

/** Убирает «г.» из `aria-label`/`title` элемента; ничего не пишет, если менять нечего. */
function stripYearSuffixAttrs(el: Element) {
  for (const attr of ['aria-label', 'title']) {
    const value = el.getAttribute(attr)
    if (!value) continue
    const cleaned = value.replace(YEAR_SUFFIX_RE, '')
    if (cleaned !== value) el.setAttribute(attr, cleaned)
  }
}

/**
 * Убирает «г.» из служебных подписей дней внутри сетки FullCalendar.
 *
 * Кроме подписи периода (её формат мы задаём сами, см. `buildTitleFormatter`),
 * дату дня FullCalendar подставляет ещё и в подпись ячейки — она видна в
 * accessibility-дереве и как всплывающая подсказка. Формат этой подписи
 * библиотека наружу НЕ отдаёт: `buildNavLinkAttrs` (@fullcalendar/core)
 * форматирует дату жёстко зашитым `{year,month:'long',day:'numeric'}` и кладёт
 * результат в `aria-label` (в `title` — когда включены navLinks), а с локалью
 * `ru` Intl всегда даёт «20 июля 2026 г.». Настройкой это не убирается,
 * поэтому правим уже отрендеренный атрибут.
 *
 * Почему MutationObserver, а не хуки `dayCellDidMount`/`dayHeaderDidMount`:
 * при переключении недельного вида на месячный FullCalendar ПЕРЕИСПОЛЬЗУЕТ
 * уже смонтированные ячейки — `didMount` для них повторно не зовётся, а
 * подпись внутри перерисовывается заново, и «г.» возвращается (поймали на
 * живой проверке: после переключения вида ровно строка переиспользованных
 * ячеек оставалась с «г.»). Наблюдатель же ловит любую перерисовку.
 * Зацикливания нет: `stripYearSuffixAttrs` пишет атрибут, только если он
 * реально меняется, поэтому вызванная нашей же правкой итерация ничего не
 * пишет и цепочка обрывается.
 */
function useStripYearSuffix(rootRef: React.RefObject<HTMLElement | null>) {
  useEffect(() => {
    const root = rootRef.current
    if (!root) return
    const clean = () => root.querySelectorAll(DAY_LABEL_SELECTOR).forEach(stripYearSuffixAttrs)
    clean()
    const observer = new MutationObserver(clean)
    observer.observe(root, { subtree: true, childList: true, attributeFilter: ['aria-label', 'title'] })
    return () => observer.disconnect()
  }, [rootRef])
}

/**
 * Форма аргумента, который FullCalendar передаёт функции-`titleFormat`
 * (`VerboseFormattingArg` из @fullcalendar/core/internal-common — не
 * реэкспортируется из публичного `@fullcalendar/core`, поэтому типизируем
 * структурно сами, только нужные поля).
 */
interface TitleFormatArg {
  start: { marker: Date }
  end?: { marker: Date }
}

/**
 * Строит titleFormat-функцию FullCalendar на Intl.DateTimeFormat вместо
 * ruLocale-зависимого форматтера — тот всегда добавляет «г.» после года.
 * FullCalendar передаёт границы диапазона уже с учётом exclusivity (конец
 * периода минус 1мс, см. `dateEnv.formatRange` в @fullcalendar/core) — этого
 * достаточно, чтобы Intl.formatRange сам корректно схлопнул одинаковые
 * месяц/год и красиво отформатировал переход через месяц/год.
 */
function buildTitleFormatter(options: Intl.DateTimeFormatOptions) {
  const formatter = new Intl.DateTimeFormat('ru-RU', { ...options, timeZone: 'UTC' })
  return (arg: TitleFormatArg): string => {
    const raw = arg.end ? formatter.formatRange(arg.start.marker, arg.end.marker) : formatter.format(arg.start.marker)
    return raw.replace(YEAR_SUFFIX_RE, '')
  }
}

const CALENDAR_VIEWS = {
  dayGridWeek: { titleFormat: buildTitleFormatter({ year: 'numeric', month: 'long', day: 'numeric' }) },
  dayGridMonth: { titleFormat: buildTitleFormatter({ year: 'numeric', month: 'long' }) },
}

/**
 * Обёртка над FullCalendar (см. design/mockups/calendar.html): недельная
 * (только будни, 5 колонок) / месячная сетка вхождений конференций, свой
 * тулбар (вид, навигация по датам) вместо встроенного headerToolbar.
 * События рендерятся как «чипы» (`.conf-chip`) — только название (+карандашик
 * у владельца), статус/время/номер — в информационном окне по клику
 * (`ConferenceOccurrenceDialog`) — вместо позиционирования по времени
 * (timeGrid), т.к. менеджер конференций показывает список вхождений по дням,
 * не занятость переговорной по часам (переговорных комнат в модели нет).
 */
export function ConferenceCalendar({
  view,
  onViewChange,
  occurrences,
  isLoading,
  myConferenceById,
  onRangeChange,
  onOccurrenceClick,
  onEditClick,
}: ConferenceCalendarProps) {
  const calendarRef = useRef<FullCalendar | null>(null)
  const titleRef = useRef<HTMLSpanElement | null>(null)
  const gridRef = useRef<HTMLDivElement | null>(null)

  useStripYearSuffix(gridRef)

  // См. комментарий у `latestOnEditClick` в начале файла — синхронизируем
  // переменную вместо того, чтобы замыкать пропс напрямую в renderEventContent.
  useEffect(() => {
    latestOnEditClick = onEditClick
  }, [onEditClick])

  // Ховер-карточка конференции — состояние держим внутри
  // компонента (не поднимаем в CalendarPage), чтобы колбэки FullCalendar ниже
  // оставались стабильными по identity между рендерами (см. комментарий в
  // начале файла про resetOptions/бесконечный цикл при нестабильных пропсах).
  const [hoverTarget, setHoverTarget] = useState<{ occurrence: OccurrenceOut; anchorEl: HTMLElement } | null>(null)

  const handleEventMouseEnter = useCallback((arg: EventHoveringArg) => {
    setHoverTarget({ occurrence: arg.event.extendedProps.occurrence as OccurrenceOut, anchorEl: arg.el })
  }, [])

  const handleEventMouseLeave = useCallback((arg: EventHoveringArg) => {
    setHoverTarget((prev) => (prev && prev.anchorEl === arg.el ? null : prev))
  }, [])

  // Клавиатурная доступность ховер-карточки: чипы FullCalendar рендерятся
  // самой библиотекой вне React-дерева, поэтому фокус отслеживаем нативными
  // слушателями focusin/focusout (не React onFocus/onBlur).
  const handleEventDidMount = useCallback((arg: EventMountArg) => {
    arg.el.tabIndex = 0
    const occurrence = arg.event.extendedProps.occurrence as OccurrenceOut
    const handleFocusIn = () => setHoverTarget({ occurrence, anchorEl: arg.el })
    const handleFocusOut = () => setHoverTarget((prev) => (prev && prev.anchorEl === arg.el ? null : prev))
    arg.el.addEventListener('focusin', handleFocusIn)
    arg.el.addEventListener('focusout', handleFocusOut)
    eventFocusCleanups.set(arg.el, () => {
      arg.el.removeEventListener('focusin', handleFocusIn)
      arg.el.removeEventListener('focusout', handleFocusOut)
    })
  }, [])

  const handleEventWillUnmount = useCallback((arg: EventMountArg) => {
    eventFocusCleanups.get(arg.el)?.()
    eventFocusCleanups.delete(arg.el)
  }, [])

  useEffect(() => {
    calendarRef.current?.getApi().changeView(view)
  }, [view])

  function goPrev() {
    calendarRef.current?.getApi().prev()
  }
  function goNext() {
    calendarRef.current?.getApi().next()
  }
  function goToday() {
    calendarRef.current?.getApi().today()
  }

  // Мемоизировано по `occurrences`/`myConferenceById` — иначе на каждый рендер
  // создавался бы новый массив-`events` той же длины/содержимого, и
  // FullCalendar считал бы его «изменившейся опцией» (см. комментарий у
  // модульных констант выше).
  const events: EventInput[] = useMemo(
    () =>
      occurrences.map((occ) => {
        const owned = myConferenceById.get(occ.conference_id)
        return {
          id: `${occ.conference_id}:${occ.starts_at}`,
          title: occ.title ?? 'Конференция без названия',
          start: occ.starts_at,
          end: occ.ends_at,
          classNames: ['conf-chip'],
          extendedProps: { occurrence: occ, ownedConference: owned?.is_owner ? owned : undefined },
        }
      }),
    [occurrences, myConferenceById],
  )

  const handleEventClick = useCallback(
    (info: EventClickArg) => {
      onOccurrenceClick(info.event.extendedProps.occurrence as OccurrenceOut)
    },
    [onOccurrenceClick],
  )

  const handleDatesSet = useCallback(
    (arg: DatesSetArg) => {
      const api = calendarRef.current?.getApi()
      if (api && titleRef.current) titleRef.current.textContent = api.view.title
      onRangeChange(arg.start.toISOString(), arg.end.toISOString())
    },
    [onRangeChange],
  )

  return (
    <section className="week-card">
      <div className="week-toolbar">
        <div className="date-nav">
          <button type="button" className="date-nav-arrow" aria-label="Предыдущий период" onClick={goPrev}>
            <ChevronLeft className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
          </button>
          <span ref={titleRef} />
          <button type="button" className="date-nav-arrow" aria-label="Следующий период" onClick={goNext}>
            <ChevronRight className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
          </button>
          <button type="button" className="btn btn-secondary" onClick={goToday}>
            Сегодня
          </button>
        </div>
        <div className="view-toggle" role="group" aria-label="Вид календаря">
          <button type="button" className={view === 'dayGridWeek' ? 'is-active' : ''} onClick={() => onViewChange('dayGridWeek')}>
            Неделя
          </button>
          <button type="button" className={view === 'dayGridMonth' ? 'is-active' : ''} onClick={() => onViewChange('dayGridMonth')}>
            Месяц
          </button>
        </div>
      </div>

      <div className="vc-calendar" aria-busy={isLoading} ref={gridRef}>
        <FullCalendar
          ref={calendarRef}
          plugins={CALENDAR_PLUGINS}
          initialView={view}
          headerToolbar={false}
          locale={ruLocale}
          timeZone="local"
          firstDay={1}
          hiddenDays={view === 'dayGridWeek' ? HIDDEN_DAYS_BUSINESS_WEEK : HIDDEN_DAYS_NONE}
          height="auto"
          dayMaxEvents={false}
          views={CALENDAR_VIEWS}
          events={events}
          eventContent={renderEventContent}
          eventClick={handleEventClick}
          datesSet={handleDatesSet}
          eventMouseEnter={handleEventMouseEnter}
          eventMouseLeave={handleEventMouseLeave}
          eventDidMount={handleEventDidMount}
          eventWillUnmount={handleEventWillUnmount}
        />
      </div>

      <ConferenceHoverCard conferenceId={hoverTarget?.occurrence.conference_id ?? null} anchorEl={hoverTarget?.anchorEl ?? null} />
    </section>
  )
}
