import { FocusableElement, tabbable } from 'tabbable'
import { isMac } from './platform'

/**
 * Built-in focus region IDs for the CMS.
 *
 * These correspond to the built-in entries in
 * {@code FocusRegionJavaScriptGlobals}. Custom regions can use any
 * string ID — they do not need to be added to this enum.
 *
 * NOTE: Members are listed in default navigation order to match
 * the sort order values in {@code FocusRegionJavaScriptGlobals}
 * and the {@code DEFAULT_REGION_ORDER} fallback array below.
 */
enum RegionId {
  ModalClose = 'modal-close',
  PageHeader = 'page-header',
  PageNav = 'page-nav',
  PageGuide = 'page-guide',
  PageAskAi = 'page-ask-ai',
  LeftNav = 'left-nav',
  ContentEditTop = 'content-edit-top',
  PageMain = 'page-main',
  ContentEditDrawer = 'content-edit-drawer',
  ContentEditPreview = 'content-edit-preview',
  ContentEditRight = 'content-edit-right',
  ContentEditToolbar = 'content-edit-toolbar',
}

/**
 * Configuration for a focus region.
 *
 * The {@link id} accepts any string, allowing custom regions to participate
 * in keyboard navigation without modifying core CMS code.
 */
interface FocusRegionConfig {
  id: string
  element: HTMLElement
  preferredFocus?: () => HTMLElement | null // Optional: element to focus within the region
  label?: string // Optional: human-readable label override for skip links

  /**
   * Optional function to determine if the region is currently hidden.
   */
  isHidden?: () => boolean
}

/**
 * The data attribute used for declarative focus region registration.
 *
 * Backend Java code can render an element with this attribute and the
 * frontend will automatically register it as a focus region:
 *
 * {@code <div data-focus-region="my-region" role="region" aria-label="My Region">}
 */
const FOCUS_REGION_ATTR = 'data-focus-region'
const FOCUS_REGION_SELECTOR = `[${FOCUS_REGION_ATTR}]`

/**
 * Manages keyboard navigation between focus regions in the CMS.
 * Allows users to press Alt+Tab to cycle through page regions within
 * a specific layer (e.g., an open dialog or the main document body).
 */
class FocusRegionManager {
  /**
   * Default region order used as a fallback when the backend configuration
   * is not available.
   */
  private static readonly DEFAULT_REGION_ORDER: ReadonlyArray<string> = [
    RegionId.ModalClose,
    RegionId.PageHeader,
    RegionId.PageNav,
    RegionId.PageGuide,
    RegionId.PageAskAi,
    RegionId.LeftNav,
    RegionId.ContentEditTop,
    RegionId.PageMain,
    RegionId.ContentEditDrawer,
    RegionId.ContentEditPreview,
    RegionId.ContentEditRight,
    RegionId.ContentEditToolbar,
  ]

  /**
   * Resolves the region order from the backend configuration
   * ({@code window.BRIGHTSPOT.focusRegions}). The backend provides a
   * map of region ID → numeric sort order; this method sorts entries
   * by value and returns the IDs in order. Falls back to
   * {@link DEFAULT_REGION_ORDER} if the backend config is unavailable.
   *
   * Called once at class load time — the backend config must be present
   * in the page before v5.js loads for it to take effect.
   */
  private static resolveRegionOrder(): ReadonlyArray<string> {
    const focusRegions = window.BRIGHTSPOT?.focusRegions as
      | Record<string, unknown>
      | undefined

    if (
      !focusRegions ||
      typeof focusRegions !== 'object' ||
      Object.keys(focusRegions).length === 0
    ) {
      return FocusRegionManager.DEFAULT_REGION_ORDER
    }

    const entries = Object.entries(focusRegions)
    const invalidEntries = entries.filter(
      ([, v]) => typeof v !== 'number' || !isFinite(v as number),
    )

    if (invalidEntries.length > 0) {
      console.warn(
        'FocusRegionManager: focusRegions config contains non-numeric values; falling back to default order.',
        Object.fromEntries(invalidEntries),
      )
      return FocusRegionManager.DEFAULT_REGION_ORDER
    }

    return (entries as [string, number][])
      .sort((a, b) => a[1] - b[1])
      .map(([id]) => id)
  }

  /**
   * Defines the order of regions for keyboard navigation.
   * Sourced from the backend via {@code window.BRIGHTSPOT.focusRegions},
   * with a hardcoded fallback.
   */
  private static readonly REGION_ORDER: ReadonlyArray<string> =
    FocusRegionManager.resolveRegionOrder()

  private registeredRegions: Map<HTMLElement, Map<string, FocusRegionConfig>> =
    new Map()

  /**
   * Tracks the last focused element per region per layer.
   * Structure: Layer → RegionId → Last Focused Element
   */
  private lastFocusedElements: Map<HTMLElement, Map<string, HTMLElement>> =
    new Map()
  private keyboardHandler: ((e: KeyboardEvent) => void) | null = null

  private constructor() {
    this.initKeyboardListeners()
  }

  private static instance: FocusRegionManager | null = null

  /**
   * Retrieves the singleton instance of FocusRegionManager.
   */
  static getInstance(): FocusRegionManager {
    if (!this.instance) {
      this.instance = new FocusRegionManager()
    }
    return this.instance
  }

  /**
   * Registers a focus region for keyboard navigation.
   * Regions are associated with their closest layer (e.g., a dialog or the main document body).
   */
  register(config: FocusRegionConfig): void {
    if (!config.id || !config.element) {
      console.warn(
        'FocusRegionManager: Invalid config provided to register()',
        config,
      )
      return
    }

    // Find the layer for this region and any existing regions within that layer
    // Priority: fullscreen container > open dialog > document.body
    const layer =
      config.element.closest<HTMLElement>(
        '.ProseMirror-fullscreen-container',
      ) ??
      config.element.closest<HTMLElement>('dialog[open]') ??
      document.body
    const regions = this.registeredRegions.get(layer) || new Map()

    regions.set(config.id, config)
    this.registeredRegions.set(layer, regions)
  }

  /**
   * Registers a focus region from a DOM element that has a
   * {@code data-focus-region} attribute. This is the declarative
   * counterpart to {@link register}, used by the auto-discovery system.
   *
   * If the element has already been registered imperatively (e.g., by
   * code in v5.ts with custom {@code isHidden} or {@code preferredFocus}
   * callbacks), the imperative registration takes precedence.
   */
  registerFromAttribute(element: HTMLElement): void {
    const id = element.getAttribute(FOCUS_REGION_ATTR)
    if (!id) return

    // Find the layer for this element
    const layer =
      element.closest<HTMLElement>('.ProseMirror-fullscreen-container') ??
      element.closest<HTMLElement>('dialog[open]') ??
      document.body
    const regions = this.registeredRegions.get(layer)

    // Don't overwrite an existing imperative registration for the same ID
    if (regions?.has(id)) return

    if (!FocusRegionManager.REGION_ORDER.includes(id)) {
      console.warn(
        `FocusRegionManager: region "${id}" is not in REGION_ORDER and will be excluded from navigation.`,
      )
      return
    }

    this.register({ id, element })
  }

  /**
   * Destroys all regions associated with a specific layer (e.g., when dialog is closed).
   */
  destroyLayer(layer: HTMLElement): void {
    this.registeredRegions.delete(layer)
    this.lastFocusedElements.delete(layer)
  }

  /**
   * Checks if a region is currently visible and should be included in navigation.
   */
  private isRegionVisible(config: FocusRegionConfig): boolean {
    const element = config.element

    if (
      !element.isConnected ||
      element.hasAttribute('inert') ||
      element.hidden ||
      !element.checkVisibility({
        visibilityProperty: true,
        opacityProperty: true,
      })
    ) {
      return false
    }

    if (config.isHidden && config.isHidden()) {
      return false
    }

    if (!this.hasFocusableDescendants(element)) {
      return false
    }

    return true
  }

  /**
   * Returns all registered regions that are currently visible in the specified layer,
   * sorted according to REGION_ORDER.
   */
  getVisibleRegions(layer: HTMLElement): FocusRegionConfig[] {
    if (!layer || !layer.isConnected || !this.registeredRegions.has(layer)) {
      console.warn(
        'FocusRegionManager: Specified layer is not valid for getting visible regions',
        layer,
      )
      return []
    }
    return (
      FocusRegionManager.REGION_ORDER.filter((id) =>
        this.registeredRegions.get(layer)?.has(id),
      )
        // we can be confident the following get calls won't return undefined due to the filter above
        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
        .map((id) => this.registeredRegions.get(layer)!.get(id)!)
        .filter((config) => this.isRegionVisible(config))
    )
  }

  /**
   * Returns the index of the currently focused region within the provided regions array.
   */
  private getCurrentIndex(regions: FocusRegionConfig[]): number {
    const currentFocus = document.activeElement as HTMLElement | null
    const currentRegion = regions.findIndex((region) =>
      region.element.contains(currentFocus),
    )

    return currentRegion
  }

  /**
   * Saves the currently focused element for its containing region.
   */
  private saveCurrentFocus(
    layer: HTMLElement,
    regions: FocusRegionConfig[],
  ): void {
    const activeElement = document.activeElement as HTMLElement
    if (activeElement === document.body) return

    const currentRegion = regions.find((region) =>
      region.element.contains(activeElement),
    )
    if (!currentRegion) return

    let layerMap = this.lastFocusedElements.get(layer)
    if (!layerMap) {
      layerMap = new Map()
      this.lastFocusedElements.set(layer, layerMap)
    }
    layerMap.set(currentRegion.id, activeElement)
  }

  /**
   * Navigates to the next available focus region in the specified layer.
   */
  nextRegion(layer: HTMLElement): void {
    const regions = this.getVisibleRegions(layer)
    if (regions.length === 0) return
    this.saveCurrentFocus(layer, regions)
    const nextIndex = (this.getCurrentIndex(regions) + 1) % regions.length
    this.focusRegion(regions[nextIndex], layer)
  }

  /**
   * Navigates to the previous available focus region in the specified layer.
   */
  previousRegion(layer: HTMLElement): void {
    const regions = this.getVisibleRegions(layer)
    if (regions.length === 0) return
    this.saveCurrentFocus(layer, regions)
    const currentIndex = this.getCurrentIndex(regions)
    const prevIndex = currentIndex < 1 ? regions.length - 1 : currentIndex - 1
    this.focusRegion(regions[prevIndex], layer)
  }

  /**
   * Focuses a specific region using the following priority:
   * 1. Last focused element in the region (if previously visited)
   * 2. preferredFocus (if specified)
   * 3. First tabbable element in the region
   * 4. The region element itself
   */
  focusRegion(config: FocusRegionConfig, layer: HTMLElement): void {
    let targetElement: FocusableElement | null = null

    const lastFocused = this.lastFocusedElements.get(layer)?.get(config.id)
    if (lastFocused?.isConnected && this.isElementFocusable(lastFocused)) {
      targetElement = lastFocused
    }

    if (!targetElement) {
      const preferredFocus = config.preferredFocus?.()
      if (
        preferredFocus &&
        preferredFocus.isConnected &&
        this.isElementFocusable(preferredFocus)
      ) {
        targetElement = preferredFocus
      }
    }

    if (!targetElement) {
      try {
        const tabbableElements = tabbable(config.element)
        for (const el of tabbableElements) {
          if (this.isElementFocusable(el as HTMLElement)) {
            targetElement = el
            break
          }
        }
      } catch (e) {
        console.warn('FocusRegionManager: Error finding tabbable elements', e)
      }
    }

    if (!targetElement) {
      targetElement = config.element
    }

    try {
      targetElement?.focus()
      targetElement?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
      targetElement?.classList.add('is-focusRegionTarget')
      setTimeout(() => {
        targetElement?.classList.remove('is-focusRegionTarget')
        targetElement?.classList.add('was-focusRegionTarget')
        targetElement?.addEventListener(
          'transitionend',
          () => {
            targetElement?.classList.remove('was-focusRegionTarget')
          },
          { once: true },
        )
      }, 500)
    } catch (e) {
      console.warn(
        'FocusRegionManager: Error focusing element',
        e,
        targetElement,
      )
    }
  }

  private isElementFocusable(element: HTMLElement): boolean {
    return (
      !element.hasAttribute('inert') &&
      !element.hidden &&
      element.checkVisibility({
        visibilityProperty: true,
        opacityProperty: true,
      })
    )
  }

  private hasFocusableDescendants(element: HTMLElement): boolean {
    try {
      const tabbableElements = tabbable(element)
      for (const el of tabbableElements) {
        if (this.isElementFocusable(el as HTMLElement)) {
          return true
        }
      }
    } catch (e) {
      console.warn(
        'FocusRegionManager: Error finding tabbable elements',
        e,
        element,
      )
    }
    return false
  }

  /**
   * Finds the topmost layer (fullscreen container, open dialog, or body) for focus navigation.
   */
  getTopLayer(): HTMLElement {
    const fullscreenContainer = document.querySelector<HTMLElement>(
      '.ProseMirror-fullscreen-container:not(.hide)',
    )
    if (fullscreenContainer) {
      return fullscreenContainer
    }

    const openDialogs =
      document.querySelectorAll<HTMLDialogElement>('dialog[open]')
    return openDialogs.length > 0
      ? openDialogs[openDialogs.length - 1]
      : document.body
  }

  /**
   * Resolves a human-readable label for a region, using:
   * 1. config.label (explicit override)
   * 2. element aria-label attribute
   * 3. Text content of the element referenced by aria-labelledby
   * Returns null if no label can be resolved.
   */
  getRegionLabel(config: FocusRegionConfig): string | null {
    if (config.label) {
      return config.label
    }

    const element = config.element
    const ariaLabel = element.getAttribute('aria-label')
    if (ariaLabel) {
      return ariaLabel
    }

    const labelledBy = element.getAttribute('aria-labelledby')
    if (labelledBy) {
      const labelEl = document.getElementById(labelledBy)
      if (labelEl?.textContent) {
        return labelEl.textContent.trim()
      }
    }

    return null
  }

  private isLayerRegistered(layer: HTMLElement): boolean {
    return this.registeredRegions.has(layer)
  }

  /**
   * Checks if the given keyboard event matches the region-navigation shortcut.
   * - macOS: Alt+Tab / Alt+Shift+Tab
   * - Windows/Linux: F6 / Shift+F6 (Alt+Tab is intercepted by the OS)
   */
  private isRegionShortcut(e: KeyboardEvent): boolean {
    if (e.altKey && e.key === 'Tab') return true
    if (!isMac && e.key === 'F6') return true
    return false
  }

  /**
   * Handles keyboard navigation between the focus regions.
   */
  private initKeyboardListeners(): void {
    this.keyboardHandler = (e: KeyboardEvent) => {
      if (this.isRegionShortcut(e)) {
        const topLayer = this.getTopLayer()
        if (!this.isLayerRegistered(topLayer)) {
          console.warn(
            'FocusRegionManager: No registered regions for the current layer',
            topLayer,
          )
          return
        }

        // At this point we know we will perform custom navigation, so it is
        // safe to override the browser's default behavior.
        e.preventDefault()
        e.stopPropagation()

        if (e.shiftKey) {
          this.previousRegion(topLayer)
        } else {
          this.nextRegion(topLayer)
        }
      }
    }

    document.addEventListener('keydown', this.keyboardHandler, true)
  }
}

export const focusRegionManager = FocusRegionManager.getInstance()
export { RegionId, FOCUS_REGION_SELECTOR, type FocusRegionConfig }
