import './main/webapp/v4/Entry.js'

import './v5.css'
import {
  getToggle as getPageNavToggle,
  toggle as togglePageNav,
} from './PageNav'

import onFind from './dom/onFind'
import create from './dom/create'
import { announce } from './dom/aria'
import storage from './util/storage'
import './Celebrate'
import './ContentEditPanel'
import './Crosslinker'
import Dialog from './dialog/index'
import { getIcon } from './LucideDynamicLoader'
import './SearchControlsToggle'
import { onTransitionsEnded } from './util/transition'
import './Workstreams'
import onRemove from './dom/onRemove'
import './NavRail/index'
import Icon from './Icon/index'
import throttle from './util/throttle'
import { v1 as uuid } from 'uuid'
import {
  focusRegionManager,
  RegionId,
  FOCUS_REGION_SELECTOR,
} from './FocusRegions'
import './SkipLinks'

/*
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ┌───────────────────────────────────────────────────────┐ ┃
┃ │                  +-+-+-+-+-+-+-+-+-+                  │ ┃░░
┃ │                  |A|T|T|E|N|T|I|O|N|                  │ ┃░░
┃ │                  +-+-+-+-+-+-+-+-+-+                  │ ┃░░
┃ └───────────────────────────────────────────────────────┘ ┃░░
┃                                                           ┃░░
┃    The following is bridge code to maintain backwards     ┃░░
┃ compatibility with the v4 UI. The DOM structure, which v4 ┃░░
┃ relies on, has been preserved and is mostly unchanged. If ┃░░
┃  anything, the DOM changes are additive. A few elements   ┃░░
┃   and attributes may be new, but those should be rare.    ┃░░
┃                                                           ┃░░
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛░░
  ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
  ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
*/

// Remove any custom theme color properties from v4.
onFind('html', (html: HTMLElement) => {
  for (let i = html.style.length - 1; i >= 0; i--) {
    const nameString = html.style[i]
    if (nameString?.startsWith('--color') || nameString?.startsWith('--rgb')) {
      html.style.removeProperty(nameString)
    }
  }
})

// Modify the global page nav.
onFind('.Page-nav', (nav: HTMLElement) => {
  // Nav is always interactive in v5.
  nav.removeAttribute('inert')

  // Find the original toggle button and move it to the header.
  // Note that in the future this should be done on the server-side
  const toggle = document.querySelector<HTMLElement>('.Page-navToggle')
  if (!toggle) return
  document
    .querySelector('.Page-header')
    ?.insertAdjacentElement('afterbegin', toggle)
})

onFind('.Page-navItemToggle', (toggle: HTMLElement) => {
  const icon = new Icon()
  icon.symbol = 'chevron-down'
  toggle?.prepend(icon)
})

// Reimplement the nav toggle behavior.
onFind('.Page-navToggle', (toggle: HTMLElement) => {
  function updateToggleLabel(expanded: boolean): void {
    const label = expanded
      ? window.BRIGHTSPOT?.ui.tooltips.menu.close
      : window.BRIGHTSPOT?.ui.tooltips.menu.open
    toggle.title = label
    toggle.ariaLabel = label
  }

  toggle.onclick = () => {
    const isExpanding = toggle.ariaExpanded === 'false'
    toggle.ariaExpanded = isExpanding ? 'true' : 'false'
    updateToggleLabel(isExpanding)
    togglePageNav(isExpanding ? 'ON' : 'OFF')

    const navLinks = document.querySelectorAll<HTMLElement>(
      '.Page-main > btu-nav-rail a',
    )

    const page = toggle.closest<HTMLElement>('.Page')
    const rail = document.querySelector<HTMLElement>(
      '.Page-main > btu-nav-rail',
    )

    page?.classList.add('is-nav-animating')
    if (rail) {
      onTransitionsEnded(rail).then(() =>
        page?.classList.remove('is-nav-animating'),
      )
    } else {
      page?.classList.remove('is-nav-animating')
    }

    // When page nav is toggled close, update the tooltips to the item name
    if (!isExpanding) {
      navLinks.forEach((item: HTMLElement) => {
        item.title = item.querySelector(':scope > span')?.textContent ?? ''
      })
    } else {
      // When page nav is toggled open, update the tooltips on
      // expandable items to expand/collapse
      navLinks.forEach((item: HTMLElement) => {
        if (item.parentElement?.hasAttribute('aria-expanded')) {
          item.title = item.parentElement?.classList.contains('is-expanded')
            ? window.BRIGHTSPOT?.ui.tooltips.collapse
            : window.BRIGHTSPOT?.ui.tooltips.expand
        } else {
          item.title = ''
        }
      })
    }
  }

  const lastToggleState = getPageNavToggle()
  if (window.matchMedia('not all and (min-width: 768px)').matches) {
    toggle.setAttribute('aria-expanded', 'false')
  } else if (lastToggleState !== null) {
    toggle.setAttribute(
      'aria-expanded',
      lastToggleState === 'ON' ? 'true' : 'false',
    )
  }

  updateToggleLabel(toggle.ariaExpanded === 'true')

  // If page nav is collapsed, update the tooltips to the item name
  if (toggle.getAttribute('aria-expanded') === 'false') {
    document
      .querySelectorAll<HTMLElement>('.Page-main > btu-nav-rail a')
      .forEach((item: HTMLElement) => {
        item.title = item.querySelector(':scope > span')?.textContent ?? ''
      })
  }
})

onFind('.toolUserDisplay', (userDisplay) => {
  // Move site switcher out of `.toolUserDisplay` and into the tool title.
  const sitename = userDisplay.querySelector('.toolUserSite')
  if (sitename) {
    const toolTitle = document.querySelector('.toolTitle')
    toolTitle?.append(sitename)
  }
})

onFind('.ContentEdit-top', (top) => {
  const contentEdit = top.closest<HTMLElement>('.ContentEdit')

  const wips = top.querySelector('.WorkInProgressSaveStatus')
  if (wips) top.classList.add('has-wips')

  /*
  Moves (Banner) Messages in content edit
  into the sibling to Content edit left called "top"
  so that the message can span across the grid as designed.
  ie:

  From:
  -- ContentEdit-left ------------------
  | --- ContentEdit-main ------------
  | | --- Message ---------------
  ...

  To:
  -- ContentEdit-left ------------------
  |_____________________________________
  .
  -- ContentEdit-top -------------------
  | --- Message ---------------------
  ...
  */
  const message = contentEdit?.querySelector(
    ':scope .ContentEdit-main > .Message',
  )
  if (message) {
    top.insertAdjacentElement('afterend', message)

    if (!message.closest('.LiveBlogGrid')) {
      const content = message.closest('.Popup-content, .Page-content')
      content?.classList.add('has-message')
    }
  }

  /*
    Moves the title element into the top element.
    This is so the "ContentEdit-overlay" element will be a child of the toolbar.
  */
  const title = contentEdit?.querySelector<HTMLElement>('.ContentEdit-title')
  if (!title) return

  const overlayDropdown = title.querySelector<HTMLElement>(
    'button.ContentEdit-overlay:not(empty)',
  )
  if (!overlayDropdown) return
  top.prepend(overlayDropdown)
  top.classList.add('has-overlay')
})

/*
  Hide the search input in favor of the icon button when on mobile.
  Note that this is primarily for keyboard/screen reader a11y.
*/
onFind('.Page-header.mobile', (title) => {
  const header = title.closest('.Page-header')
  const search = header?.querySelector('.Page-search')
  if (!search) return

  search.querySelector(':scope > button')?.removeAttribute('hidden')
  search.querySelector('btu-search-input')?.setAttribute('hidden', 'true')
})

/*
  On mobile screens this adds the ability to click/toggle the page search icon.
  So the search input can show/hide like the design.
*/
onFind('.Page .Page-search', (search: HTMLElement) => {
  search.querySelector(':scope > button')?.addEventListener('click', () => {
    search.setAttribute(
      'aria-expanded',
      search.getAttribute('aria-expanded') === 'true' ? 'false' : 'true',
    )

    if (search.getAttribute('aria-expanded') === 'true') {
      // Show the search input, and hide the icon button (a11y).
      search.querySelector('btu-search-input')?.removeAttribute('hidden')
      search.querySelector(':scope > button')?.setAttribute('hidden', 'true')

      search
        .querySelector<HTMLInputElement>('btu-search-input > btu-input > input')
        ?.click()
    }
  })
})

/*
  When global search is opening, display a loading spinner in the input field.
*/
onFind(
  '.Page .Page-search > btu-search-input > btu-input > input',
  (searchInput: HTMLElement) => {
    searchInput.addEventListener('click', () => {
      if (searchInput.closest('.Page:has( .Popup[name="miscSearch"])')) {
        return
      }

      searchInput.closest('.Page')?.classList.add('is-searchOpening')

      onFind(
        '.Page dialog:not(:modal)[open] .Popup.loaded.is-ready.is-visible .SearchWidget.is-transitioned',
        (widget: HTMLElement) => {
          widget.closest('.Page')?.classList.remove('is-searchOpening')
        },
      )
    })
  },
)

/*
  When global search is closed, the search input should also collapse.
  Listen on the dialog element so this fires for all close paths (close button, Escape, backdrop).
*/
onFind('btu-dialog:has(.SearchWidget.is-global)', (dialog: HTMLElement) => {
  dialog.addEventListener('btu-dialog-closed', () => {
    document
      .querySelector('.Page .Page-search')
      ?.setAttribute('aria-expanded', 'false')

    // Reset the search input visibility when on mobile.
    const search = document.querySelector('.Page-header.mobile .Page-search')
    search?.querySelector('btu-search-input')?.setAttribute('hidden', 'true')
    search?.querySelector(':scope > button')?.removeAttribute('hidden')
  })
})

/*
  When Page-content contains its own query/search field, the global search input should collapse
  by default.
*/
onFind('.Page-content:has(.SearchWidget-query)', (page: HTMLElement) => {
  page.previousElementSibling
    ?.querySelector('.Page-search')
    ?.setAttribute('aria-expanded', 'false')
})

/*
  Clones a site logo (if exists) into the site switcher logo area.
  This could be done on the backend, but it's easier to do it here for now.

  From:
  -----------------
  <div class="siteSwitch-logo"></div>
  -----------------

  To:
  -----------------
  <div class="siteSwitch-logo"> <img ...> </div>
  -----------------
*/
onFind('.siteSwitch-logo', (target) => {
  const logo = document.querySelector('.Page-title > a > img')
  if (logo) {
    target.append(logo.cloneNode())
  }
})

/** Creates a new label to show the tab item selected in a menu tab bar
 * From:
  -----------------
  <div class="TabBar is-menuOnly">
    <div class="TabBar-menu">
      <button class="TabBar-menuToggle">...</button>
      <div class="TabBar-menuList">...</div>
    </div>
  </div>
  -----------------

  To:
  -----------------
  <div class="TabBar is-menuOnly">
    <div class="TabBar-menu">
      <div class="TabBar-selected-text">...</div>
      <button class="TabBar-menuToggle">...</button>
      <div class="TabBar-menuList">...</div>
    </div>
  </div>
  -----------------
 */
onFind('.TabBar.is-menuOnly', (container: HTMLElement) => {
  const menu = container.querySelector<HTMLElement>('.TabBar-menu')
  if (!menu) return

  const selectedItem = menu.querySelector<HTMLElement>(
    ':scope .TabBar-menuList > .TabBar-menuItem.is-selected',
  )
  const tabBarSelected = create('div', {
    className: 'TabBar-selected-text',
    innerText: selectedItem?.dataset.tab,
  })
  menu.insertBefore(tabBarSelected, menu.firstElementChild)
  menu
    .querySelectorAll<HTMLElement>('.TabBar-menuItem')
    .forEach((item: HTMLElement) => {
      item.addEventListener('click', (event) => {
        event?.preventDefault()
        tabBarSelected.innerText = item.dataset.tab ?? ''
      })
    })
})

/** Adds a new data-label attribute to search result tab items
 * for icon styling the tab buttons based on their text content
 */
onFind('.SearchResult-views > ul', (tabs: HTMLElement) => {
  tabs
    .querySelectorAll<HTMLElement>(':scope > li > a')
    .forEach((tabItem: HTMLElement) => {
      const label = tabItem.textContent?.toLowerCase() ?? ''
      tabItem.dataset.label = label
      tabItem.ariaLabel = label
      tabItem.title = label
    })
})

/** Adds a disabled edit button above empty embedded guides,
 * matching style and placement of existing edit buttons.
 */
onFind('.Guide-field > .Guide-embedded:empty', (guide) => {
  const editButton = create('a', {
    className: 'Guide-edit',
    href: '#',
    disabled: true,
    'aria-disabled': true,
  })

  guide.append(editButton)
})

/**
 * Adds a styling hook to indicate that the Guide edit field
 * has dropdown actions.
 */
onFind(
  '.Popup[name="edit-guide"] .RCIG-item > btu-dropdown:not(:empty)',
  (dropdown: HTMLElement) => {
    dropdown?.closest('.RCIG-item')?.classList.add('has-dropdown-actions')
  },
)

/** Applies the following updates to table structure to fit v5 design:
 * Adds a LinkList-wrapper to LinkList tables in DashboardWidgets for horizontal scrolling
 * For LinkTable & LinkList tables, re-orders LinkTable-main or LinkList-main column so that:
 *   1). They are the first column if no checkbox column is present.
 *   2). They are the second column when checkbox column exists in the table.
 */
onFind('table', (table: HTMLElement) => {
  if (
    ['LinkList', 'LinkTable', 'term-results'].some((cls) =>
      table.classList.contains(cls),
    )
  ) {
    const headerRow = table.querySelector<HTMLElement>(':scope > thead > tr')
    const rows = table.querySelectorAll<HTMLElement>('tbody > tr')
    const main = table.querySelector<HTMLElement>(
      '.LinkTable-main, .LinkList-main, .term-stats-head',
    )
    const row = main?.closest('tr')
    if (!row || !main) return
    const hasCheckbox = table.querySelector<HTMLElement>(
      'th > input[type="checkbox"], td > input[type="checkbox"]',
    )
    if (headerRow) {
      const idx = Array.from(row.children).indexOf(main)
      let headerIdx = idx
      for (let i = 0; i < idx; i++) {
        const hItem = headerRow.children[i]
        if (hItem instanceof HTMLTableCellElement && hItem.colSpan > 1) {
          headerIdx = headerIdx - hItem.colSpan + 1
        }
      }

      const thead = headerRow.children[headerIdx]
      if (hasCheckbox) {
        headerRow.insertBefore(thead, headerRow.children[1])
      } else {
        headerRow.insertBefore(thead, headerRow.firstElementChild)
      }
    }
    rows.forEach((row) => {
      const main = row.querySelector<HTMLElement>(
        ':scope > td.LinkTable-main, :scope > td.LinkList-main',
      )
      if (!main) return
      if (hasCheckbox) {
        row.insertBefore(main, row.children[1])
      } else {
        row.insertBefore(main, row.firstElementChild)
      }
    })
  }
  if (
    table.closest(
      '.DashboardWidget:not(.widget-scheduledEvents), .SearchWidget-result, #term-finder-result',
    )
  ) {
    const cellCount = table.querySelectorAll<HTMLElement>(
      'tbody tr:first-child > td',
    ).length
    /* do not add scrolling to table if:
     * it is a single column table or
     * if it has two columns and one of those is viewers (e.g.: My Activity Widget)
     */
    if (
      cellCount === 1 ||
      (cellCount === 2 && table.querySelector('tr td.LinkList-viewers'))
    ) {
      return
    }

    const parentNode = table.parentElement
    if (!parentNode) return
    if (parentNode.childElementCount > 1) {
      const tableContainer = create('div', {
        className: 'LinkList-wrapper',
      })
      parentNode.insertBefore(tableContainer, table)
      tableContainer.appendChild(table)
    } else {
      parentNode.classList.add('LinkList-wrapper')
    }
  }
})

/* Move the pagination to bottom of Notification container. */
onFind('.Notification-pagination', (pagination) => {
  pagination
    .closest('.ToolUserNotifications')
    ?.querySelector('.Notification')
    ?.insertAdjacentElement('beforeend', pagination)
})

/* Move the PaginatedResult (PR) pagination after table. */
onFind('.PR-pagination', (pagination) => {
  pagination
    .closest('.PR')
    ?.querySelector('.LinkList-wrapper, .LinkTable')
    ?.insertAdjacentElement('afterend', pagination)
})

/* Move the Dashboard Widget Pagination. */
onFind('.DashboardWidget-pagination', (pagination) => {
  // Move to bottom of workstream widget
  if (pagination.closest('.p-workStreams')) {
    pagination
      .closest('.p-workStreams')
      ?.querySelector('.block:not(:has(+ .block))')
      ?.insertAdjacentElement('afterend', pagination)
  } else {
    /* moves pagination after tables for non-calendar dashboard widgets */
    pagination
      .closest('.DashboardWidget:not(.widget-scheduledEvents)')
      ?.querySelector('.LinkList-wrapper, .LinkTable, .LinkList')
      ?.insertAdjacentElement('afterend', pagination)
  }
})

/* Upgrade the v4 copy button to a btu-copy-to-clipboard element. */
onFind(
  '.LookingGlass-actions[data-action="copy"]',
  (legacyElement: HTMLElement) => {
    const copyToClipboard = document.createElement('btu-copy-to-clipboard')
    copyToClipboard.classList.add(...legacyElement.classList)
    const key = legacyElement.querySelector(':scope > a')?.getAttribute('href')
    const title = legacyElement.querySelector(':scope > a')?.textContent ?? ''
    const copySuccessMessage =
      legacyElement.querySelector(':scope > span')?.textContent ?? ''
    if (!key) return

    copyToClipboard.copydata = key
    copyToClipboard.title = title
    copyToClipboard.label = title

    legacyElement.parentNode?.insertBefore(copyToClipboard, legacyElement)
    legacyElement.remove()

    copyToClipboard.addEventListener(
      'btu-copy-to-clipboard-copied',
      (event) => {
        if (event.target instanceof HTMLElement) {
          const button = event.target.querySelector('button')
          if (button) button.dataset.successLabel = copySuccessMessage
        }

        if (copySuccessMessage) {
          announce(copySuccessMessage)
        }
      },
    )
  },
)

/*
  This checks for when the mobile breakpoint is reached
  and adds a classname of 'mobile' to the page header.
  Normally we wouldn't do this, but it's easier to share styles
  when you can stack query selectors like:
  ---------
  .Page-header.mobile,
  .Page-header:has(.ContentEdit-title--v5) {
    <shared styles>
  }
  ---------
  also, @media and @apply cannot be used in conjunction.
*/
onFind('.Page-header', (header) => {
  const tabletQuery = window.matchMedia('not all and (min-width: 768px)')
  function handleBreakpoint(matches: boolean) {
    const search = header.querySelector('.Page-search')
    if (matches) {
      header.classList.add('mobile')
      document.body.classList.remove('has-navrail-expanded')
      // Restore mobile search visibility: show icon button, hide input.
      // Reset aria-expanded so CSS driven by it (which hides the button) stays in sync.
      if (search) {
        search.setAttribute('aria-expanded', 'false')
        search.querySelector(':scope > button')?.removeAttribute('hidden')
        search.querySelector('btu-search-input')?.setAttribute('hidden', 'true')
      }
    } else {
      header.classList.remove('mobile')
      // Restore desktop search visibility: show input, hide icon button.
      document.body.classList.toggle(
        'has-navrail-expanded',
        getPageNavToggle() === 'ON',
      )

      // Restore search visibility when switching from mobile to desktop.
      if (search) {
        search.querySelector('btu-search-input')?.removeAttribute('hidden')
        search
          .querySelector<HTMLElement>(':scope > button')
          ?.setAttribute('hidden', 'true')
      }
    }
  }
  handleBreakpoint(tabletQuery.matches)
  tabletQuery.addEventListener('change', (ev) => handleBreakpoint(ev.matches))
})

/* Formats day of week labels in date picker to show only first two letters */
onFind(
  ['.DateStringCalendar .Month-dowLabel', '.Week-day > .Week-dow'],
  (dateLabelEl: HTMLElement) => {
    const label = dateLabelEl.innerText.substring(0, 2)
    dateLabelEl.innerText = label
  },
)

/*
  Change the custom property used in v5 for status color so that
  it won't conflict and be overridden by the workflow color in v4.

  Note the variety of markup used for status labels in the v4 UI.
*/
onFind(
  '.Revisions-statusLabel, .Message.is-revision, .ContentSummary-link > .Label, .ComboInput-selected .Label, .ContentSelector-search > .Label, .LinkTable-label > .Label',
  (statusLabel: HTMLElement) => {
    const workflowColorFromDataset = statusLabel.dataset.statusColor
    const style = statusLabel.style
    const workflowColor =
      workflowColorFromDataset ?? style.getPropertyValue('background')

    if (workflowColor) {
      style.setProperty('--status-color', workflowColor)
      statusLabel.dataset.statusColor = workflowColor
      style.removeProperty('background')
      style.removeProperty('color')
    } else {
      style.removeProperty('--workflow-color')
    }
  },
)

/*
  Gives reply button inner text "Reply" or "Reply (1)" based on the number of replies.
  Also moves the reply button to the end of the post.
*/
onFind('.Conversation-post .Conversation-reply', (reply: HTMLElement) => {
  if (reply.innerText) {
    reply.innerText = reply.title + ' (' + reply.innerText + ')'
  } else {
    reply.innerText = reply.title
  }

  const post = reply.closest('.Conversation-post')
  post?.append(reply)
})

/* adds title attributes for ul.LinkTable list items to show the entire text on hover */
onFind('ul.LinkTable', (list: HTMLElement) => {
  const liItems = list.querySelectorAll<HTMLElement>(':scope > li')
  liItems.forEach((item: HTMLElement) => {
    item = item.querySelector('.ViewWatchers-item-name') || item
    item.title = item.textContent || ''
  })
})

onFind('.DashboardWidget-maximize', (maximize: HTMLElement) => {
  if (maximize.closest('.DashboardRow-cell')?.matches('.is-maximized')) {
    maximize.setAttribute('aria-expanded', 'true')
  }

  maximize.addEventListener('click', async (event) => {
    const target = event.target
    if (!(target instanceof HTMLElement)) return

    const toggleMaximize = (widget: HTMLElement) => {
      if (widget instanceof HTMLElement) {
        if (maximize.toggleAttribute('aria-expanded')) {
          widget.classList.add('is-maximized')
          document.body.classList.add('has-dashboardWidgetMaximized')
        } else {
          widget.classList.remove('is-maximized')
          document.body.classList.remove('has-dashboardWidgetMaximized')
        }
      }
    }

    const dc = target.closest<HTMLElement>('.DashboardRow-cell')
    if (!dc) return

    // Transition the backdrop separately to avoid unwanted transitions.
    if (!dc.classList.contains('is-maximized')) {
      document.body.classList.add('transition-backdrop')
      dc.classList.add('transition-dashboard-widget')
      const transition = document.startViewTransition(() => toggleMaximize(dc))
      await transition.finished
      // De-reference the unique view transition id when the transition is complete.
      dc.classList.remove('transition-dashboard-widget')
    } else {
      document.body.classList.remove('transition-backdrop')
      toggleMaximize(dc)
    }

    maximize.focus()
  })
})

/* Adds focus point toggle to enable/disable focus ui on image preview */
onFind('.ImageEditor-groups', (imageSizeGroups: HTMLElement) => {
  const focusToggle = create('button', {
    className: 'ImageEditor-focusToggle',
    type: 'button',
  })
  let groupsDropdown: HTMLElement | null = null
  imageSizeGroups.prepend(focusToggle)
  focusToggle.addEventListener('click', (event) => {
    const isActive = imageSizeGroups.classList.toggle('is-focus-active')
    if (!groupsDropdown) {
      groupsDropdown = imageSizeGroups.querySelector<HTMLElement>(
        ':scope > .ComboInput',
      )
    }
    if (!groupsDropdown) return
    groupsDropdown.tabIndex = isActive ? 0 : -1
  })
})

/* Copies search reset button tooltip to inner text */
onFind('.SearchWidget-reset', (reset: HTMLElement) => {
  reset.innerText = reset.title?.split(' ')[0]
})

/*
  Pulls out the status label from image search result caption so that it can be styled separately.
 */
onFind(
  '.SearchResult-images a[data-label-html] > figcaption > .contentTitle--v5 > .Label',
  (status: HTMLElement) => {
    status.classList.add('contentStatus--v5')
    status.parentElement?.insertBefore(status, status.parentElement.firstChild)
  },
)

/*
  Copies image height style from the SearchResult-images dataset so that the CSS can access it.
*/
onFind('.SearchResult-images[data-height]', (images: HTMLElement) => {
  images.style.setProperty('--image-height', images.dataset.height + 'dvh')

  const observer = new MutationObserver((mutationList) => {
    for (const mutation of mutationList) {
      if (
        mutation.type === 'attributes' &&
        mutation.attributeName === 'data-height' &&
        images.dataset.height
      ) {
        images.style.setProperty(
          '--image-height',
          images.dataset.height + 'dvh',
        )
      }
    }
  })

  observer.observe(images, {
    attributes: true,
    attributeFilter: ['data-height'],
  })
})

/*
  Adds a toggle button to show/hide the image height input field in the search result.
*/
onFind('.SearchResult-imageHeightInput', (input: HTMLInputElement) => {
  const button = create('button', {
    className: 'SearchResult-imageHeightToggle v5-only',
    type: 'button',
    ariaExpanded: 'false',
    title: window.BRIGHTSPOT?.ui.tooltips.imageHeightOpen,
    ariaLabel: window.BRIGHTSPOT?.ui.tooltips.imageHeightOpen,
  })

  const container = create('div', {
    className: 'SearchResult-imageHeightContainer v5-only',
  })

  // Wrap the input in the container and insert toggle button before input.
  input.insertAdjacentElement('beforebegin', container)
  container.append(input)
  container.prepend(button)

  button?.addEventListener('click', () => {
    if (button.ariaExpanded === 'false') {
      button.ariaExpanded = 'true'
      button.title = window.BRIGHTSPOT?.ui.tooltips.imageHeightClose
      button.ariaLabel = window.BRIGHTSPOT?.ui.tooltips.imageHeightClose
      input?.classList.add('is-open')
    } else {
      button.ariaExpanded = 'false'
      button.title = window.BRIGHTSPOT?.ui.tooltips.imageHeightOpen
      button.ariaLabel = window.BRIGHTSPOT?.ui.tooltips.imageHeightOpen
      input?.classList.remove('is-open')
    }
  })
})

/* Move the ask-ai wrapper in a level so it can participate in the grid layout. */
onFind('.Page-askAi', (askAi) => {
  document
    .querySelector('.Page-container')
    ?.insertAdjacentElement('beforebegin', askAi)
  document.body.classList.add('has-ask-ai')
})

/*
  Move the notifications panel into the nav rail. In the future this should be
  done on the server-side
*/
onFind('.Page-notifications', (notifications: HTMLElement) => {
  const link = notifications.querySelector('a')
  const icon = new Icon()
  icon.symbol = 'bell'
  icon.fill = 'gradient'
  const label = document.createElement('span')
  label.textContent = window.BRIGHTSPOT?.ui.tooltips.notifications
  link?.appendChild(icon)
  link?.appendChild(label)

  const navRail = document.querySelector('btu-nav-rail')
  if (!navRail) return
  const avatar = navRail.querySelector('.Page-avatar')
  const avatarLi = avatar?.closest('li')
  if (avatarLi) {
    const notificationsLi = document.createElement('li')
    notificationsLi.appendChild(notifications)
    navRail.querySelector('ul')?.insertBefore(notificationsLi, avatarLi)
  } else {
    navRail.addItem(notifications)
  }
})

/*
  Add wrapper to the frames in Create with AI popup.
  Needed to keep the scroll position at the bottom when the frames are updating on response.
*/
onFind('.Widget-title + .AIChat-frame', (frame: HTMLElement) => {
  const wrapper = document.createElement('div')
  wrapper.classList.add('AIChat-wrapper')
  wrapper.classList.add('!px-0')
  frame.parentElement?.insertBefore(wrapper, frame)
  wrapper.appendChild(frame)
})

/*
  In board view there's an additional ComboInput and new parent element for
  holding the "show fields" button. To display these in a way
  that makes sense, we make the board fields parent adopt the default sorts.

  From:
  -----------------
  [Board Fields]
  [Default Sorts]
  -----------------

  To:
  -----------------
  [Board Fields]
  |- [Default Sorts]
  -----------------
*/
onFind('.SearchResult-board-fields', (bfContainer) => {
  const ctx = bfContainer.closest('.SearchResult')
  if (!ctx) return
  const defaultSorts = ctx.querySelector('.SearchResult-sorts')
  if (!defaultSorts) return
  bfContainer.prepend(defaultSorts)
})

/* Unhides the progressBar inside Content Edit workstreams banner
  From:
  -----------------
    <div class="progressBar" style="width:" hidden>
  -----------------

  To:
  -----------------
    <div class="progressBar" style="--progressPct:">
    |- ::before
    </div
  -----------------
*/
onFind('.ContentEdit-workstreamProgress .progressBar', (bar: HTMLElement) => {
  bar.classList.remove('hidden')
  bar.removeAttribute('hidden')
  const progressPct = bar.style.width
  bar.style.removeProperty('width')
  bar.style.setProperty('--progressPct', progressPct)
})

/* Adds toggle functionality to the filters in The Shelf when it is in the right rail */
onFind('.ContentEditDrawer-widget-filters', (filters) => {
  const isExpanded = storage.get('BSP.ContentEdit.shelfFilters') === 'EXPANDED'
  const mainDrawer = filters.closest('.ContentEditDrawer')
  const drawerWidgetFrame = filters.closest('.ContentEditDrawer-widget')
  if (!mainDrawer || !drawerWidgetFrame) return
  const label = isExpanded
    ? window.BRIGHTSPOT?.ui.tooltips.filtersHide
    : window.BRIGHTSPOT?.ui.tooltips.filtersShow
  const button = create('button', {
    className: 'ContentEditDrawer-filtersToggle v5-only',
    type: 'button',
    title: label,
    ariaLabel: label,
    ariaExpanded: isExpanded ? 'true' : 'false',
  })

  filters?.classList.toggle('is-visible', isExpanded)

  // The frame gets reloaded after selecting an option in the filters.
  // Replace the toggle with a new one each time this happens so that
  // there are not multiple and it has the right event listener.
  const closeWidget = mainDrawer?.querySelector('.closeButton')
  const existingToggle = mainDrawer?.querySelector(
    '.ContentEditDrawer-filtersToggle',
  )
  existingToggle?.remove()
  closeWidget?.parentNode?.insertBefore(button, closeWidget)
  if (drawerWidgetFrame.classList.contains('is-loading')) {
    drawerWidgetFrame.classList.remove('is-loading')
  }

  button.addEventListener('click', () => {
    filters?.classList.toggle('is-visible')

    const isExpanded = filters?.classList.contains('is-visible')
    const label = isExpanded
      ? window.BRIGHTSPOT?.ui.tooltips.filtersHide
      : window.BRIGHTSPOT?.ui.tooltips.filtersShow

    storage.set(
      'BSP.ContentEdit.shelfFilters',
      isExpanded ? 'EXPANDED' : 'COLLAPSED',
    )
    button.title = label
    button.ariaLabel = label
    button.ariaExpanded = isExpanded ? 'true' : 'false'
  })
  filters.addEventListener('change', () => {
    drawerWidgetFrame.classList.add('is-loading')
  })
})

/** Live Blog Post view
  1. Highlights the selected post in left rail list of live blog posts
    If a new post is being created , New Post button is selected

  2. Mobile view styling for live blog posts:
    Creates a new toggle button that expands/collapses the live blog post list
      From:
      -- Page-main ------------------
      | --- Page-header ------------
      | | --- ContentEdit-title--v5 ---------------
      | | |      ...
      | --- Page-content ------------
      | | --- Grid (live blog posts grid)
      ...

      To:
      -- Page-main ------------------
      | --- Page-header ------------
      | | --- LiveBlogPostList-open
      | | --- ContentEdit-title--v5 ---------------
      | | |      ...
      | --- Page-content ------------
      | | --- Grid (live blog posts grid)
      ...
*/
onFind('.LiveBlogPostListNav.loaded', (listContainer: HTMLElement) => {
  const pageContainer = listContainer.closest<HTMLElement>('.Page-main')
  const gridContent = pageContainer?.querySelector<HTMLElement>('.LiveBlogGrid')

  if (!gridContent) return

  const newPostButton = listContainer.querySelector<HTMLElement>(
    '.LiveBlogPostList-controls a:not(.LiveBlogPostListNav-refresh)',
  )
  if (!newPostButton) return

  const selectNavItem = (item: HTMLElement) => {
    const prevSelected = listContainer.querySelector(
      'a.is-selected, tr.is-selected',
    )
    if (prevSelected) {
      prevSelected.classList.remove('is-selected')
    }
    item.classList.add('is-selected')
  }

  onFind('.LiveBlogGrid .ContentEdit', (form: HTMLElement) => {
    const isNewPost = form?.classList.contains('is-new')
    const formId = form?.dataset.objectId
    gridContent.classList.add('is-visible')
    const contentPanel = form.querySelector<HTMLElement>(
      '.ContentEdit-right.is-visible',
    )
    if (contentPanel) {
      contentPanel.classList.remove('is-visible')
    }
    if (isNewPost) {
      selectNavItem(newPostButton)
    } else {
      if (!formId) return
      const post = listContainer.querySelector<HTMLElement>(
        `tr[data-post-id="${formId}"]`,
      )
      if (post) {
        selectNavItem(post)
      }
    }
  })

  /* Back to list view button in mobile */
  let listOpen = gridContent.querySelector<HTMLButtonElement>(
    '.LiveBlogPostList-open',
  )
  if (!listOpen) {
    listOpen = create('button', {
      className: 'LiveBlogPostList-open',
      ariaLabel: window.BRIGHTSPOT?.ui.tooltips.listOpen,
      title: window.BRIGHTSPOT?.ui.tooltips.listOpen,
      type: 'button',
      onclick: () => {
        gridContent.classList.remove('is-visible')
        listContainer.classList.add('is-visible')
      },
    })
    gridContent.insertBefore(listOpen, gridContent.firstElementChild)
  }

  listContainer.addEventListener('click', (event) => {
    if (event.target instanceof HTMLAnchorElement) {
      const item = event.target
      if (
        item.target === 'postedit' &&
        (item.closest('.LiveBlogPostList-controls') ||
          item.closest('tr[data-post-id]'))
      ) {
        gridContent.classList.add('is-visible')
        listContainer.classList.remove('is-visible')
      }
    }
  })
})

/**
 * Resolves a Lucide icon by name and applies the corresponding CSS custom
 * properties (--compat-icon for the font glyph, --compat-icon-via-mask for
 * the SVG mask) to the given element.
 */
function applyIconProperties(name: string, el: HTMLElement): void {
  const materialToLucide = window.BRIGHTSPOT?.icons?.materialToLucide ?? {}
  const mappedName = materialToLucide[name] ?? name
  const iconData = getIcon(mappedName, el)

  el.style.setProperty('--compat-icon', `'${iconData?.compat ?? ''}'`)

  if (iconData?.encodedSvg) {
    el.style.setProperty(
      '--compat-icon-via-mask',
      `url("data:image/svg+xml,${iconData.encodedSvg}")`,
    )
  } else {
    el.style.removeProperty('--compat-icon-via-mask')
  }
}

/*
  Find all elements with icon data attributes and
  try to associate and render a corresponding Lucide icon.
 */
onFind(
  ['[data-icon]', '[data-icon-name]', '[data-icon-button-name]'],
  (el: HTMLElement) => {
    const name =
      el.dataset.icon || el.dataset.iconName || el.dataset.iconButtonName

    if (name) {
      applyIconProperties(name, el)
    }
  },
)

// Listen for 'btu-resolve-icon' events from blacklisted areas (toolbar).
document.addEventListener('btu-resolve-icon', ((
  e: CustomEvent<{ name: string; element: HTMLElement }>,
) => {
  const { name, element } = e.detail
  if (!name || !element) return

  applyIconProperties(name, element)
}) as EventListener)

/* Renders a v5 content edit title into the page header.
  A MutationObserver on `.ContentLabel` watches for label text changes and refreshes the headline.
*/
const renderCETitle = (title: HTMLElement) => {
  const label = title.querySelector<HTMLElement>('.ContentLabel')
  if (!label) return

  const editPage = title.closest<HTMLElement>('.ContentEdit')
  if (!editPage) return

  const header = editPage.querySelector<HTMLElement>('.ContentEdit-top')
  if (!header) return

  const headlineText = () =>
    (label.textContent ?? '').replace(/(\r\n|\n|\r)/gm, ' ')

  const render = () => {
    const contentType =
      title
        .querySelector<HTMLElement>('.ContentEdit-typeName')
        ?.textContent?.trim() ||
      title.querySelector<HTMLSelectElement>('select')?.selectedOptions[0]
        ?.text ||
      ''
    const status = title.querySelector<HTMLSpanElement>('.Label')

    const newTitle = document.createElement('div')
    newTitle.classList.add('ContentEdit-title--v5')

    const newContentType = document.createElement('span')
    newContentType.innerText = contentType
    newContentType.classList.add('ContentEdit-contentType--v5')

    const title2 = document.createElement('span')
    title2.innerText = headlineText()
    title2.classList.add('ContentEdit-contentTitle--v5')

    const newStatus = document.createElement('span')
    newStatus.classList.add('ContentEdit-status--v5')
    const legacyStatus = title.querySelector<HTMLSpanElement>('span.Label')
    newStatus.innerText = legacyStatus?.innerText ?? ''
    // check if the legacyStatus has a class-specified status (e.g. is-scheduled)
    if (legacyStatus) {
      const isStatus = Array.from(legacyStatus.classList).find((cls) =>
        cls.startsWith('is-'),
      )
      if (isStatus) {
        newStatus.classList.add(isStatus)
      }
    }

    const customWorkflowColor = status?.style?.getPropertyValue('background')
    if (customWorkflowColor) {
      newStatus.dataset.statusColor = customWorkflowColor
      newStatus.style.setProperty('--status-color', customWorkflowColor)
    }

    // Try to grab status name from the revision banner, as it is often more descriptive
    const statusBanner = editPage.querySelector<HTMLElement>(
      '.Message.is-revision',
    )
    const statusText = statusBanner?.querySelector<HTMLSpanElement>(
      '.ContentEdit-revisionLabel',
    )
    if (statusText) {
      newStatus.innerText = statusText.innerText

      // background: fallback for when the global status-label onFind handler hasn't yet set --status-color from data-status-color.
      const bannerColor =
        statusBanner?.dataset.statusColor ||
        statusBanner?.style.getPropertyValue('--status-color') ||
        statusBanner?.style.getPropertyValue('background')
      if (bannerColor) {
        newStatus.dataset.statusColor = bannerColor
        newStatus.style.setProperty('--status-color', bannerColor)
      }
    }

    // If status name is empty, default to either "New" or "Published"
    if (!newStatus.innerText || newStatus.innerText == '') {
      if (editPage.classList.contains('is-notNew')) {
        newStatus.innerText = window.BRIGHTSPOT?.ui.workflow.published
      }
      if (editPage.classList.contains('is-new')) {
        newStatus.innerText = window.BRIGHTSPOT?.ui.tooltips.new
      }
    }

    newTitle.replaceChildren(title2, newContentType, newStatus)

    const existingTitle = header.querySelector('.ContentEdit-title--v5')
    if (existingTitle) {
      header.replaceChild(newTitle, existingTitle)
    } else {
      header.prepend(newTitle)
    }
  }

  const observer = new MutationObserver(() => {
    // Refresh the headline in-place if already rendered; otherwise do a full render.
    const existingContentTitle = header.querySelector<HTMLElement>(
      '.ContentEdit-contentTitle--v5',
    )
    if (existingContentTitle) {
      existingContentTitle.innerText = headlineText()
    } else {
      render()
    }
  })

  observer.observe(label, {
    attributes: true,
  })
  onRemove(editPage, () => observer.disconnect())

  render()

  // Bridge.js adds the capitalized `Message` class to `.message.is-revision` asynchronously.
  // onFind fires once when it appears — this covers the case where Bridge.js runs after render().
  // If Bridge.js has already run, render() applies the color directly via `.Message.is-revision`.
  onFind(editPage, '.Message.is-revision', (statusBanner: HTMLElement) => {
    const bannerColor =
      statusBanner.dataset.statusColor ||
      statusBanner.style.getPropertyValue('--status-color') ||
      statusBanner.style.getPropertyValue('background')
    if (!bannerColor) return

    const badge = header.querySelector<HTMLElement>('.ContentEdit-status--v5')
    if (!badge) return

    const revisionLabel = statusBanner.querySelector<HTMLElement>(
      '.ContentEdit-revisionLabel',
    )
    if (revisionLabel) badge.innerText = revisionLabel.innerText

    badge.dataset.statusColor = bannerColor
    badge.style.setProperty('--status-color', bannerColor)
  })
}

if (document.readyState === 'loading') {
  window.addEventListener('DOMContentLoaded', () => {
    const title = document.querySelector<HTMLElement>('.ContentEdit-title')
    if (title) renderCETitle(title)
  })
} else {
  const title = document.querySelector<HTMLElement>('.ContentEdit-title')
  if (title) renderCETitle(title)
}

onFind(
  ['.Popup.loaded .ContentEdit-title', '.LiveBlogGrid .ContentEdit-title'],
  (title: HTMLElement) => {
    renderCETitle(title)
  },
)

/* Elements which trigger a dropdown need to be popovers so they render in a top-layer.
  This is to prevent stacking/position issues rendering them inside a modal dialog (like embedded CE). */
onFind(
  '[aria-haspopup][aria-controls]:not([aria-controls=""])',
  (trigger: HTMLElement) => {
    const popup = document.getElementById(
      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
      trigger.getAttribute('aria-controls')!,
    )
    if (popup === null) return
    popup.setAttribute('popover', '')
    trigger.addEventListener('click', () => {
      popup.showPopover()
    })
  },
)

/*
  `is-local` popups which we don't want treated as popovers
  and instead should be opened as modals
*/
function isPopoverBlacklisted(popup: HTMLElement) {
  if (!popup.dataset?.popupSourceClass) return false

  const blacklist = ['rte2-toolbar-add-table']
  return blacklist.includes(popup.dataset.popupSourceClass)
}

const renderDialogHeading = (
  root: HTMLElement | null,
  selectors: string,
  hideOriginal = true,
) => {
  if (root === null) {
    console.warn(
      'root element provided to `Dialog._renderTitle` is null. Skipping render.',
    )
    return
  }

  const cloned = root.querySelector(`:scope > :where(${selectors})`)
  const source = root.querySelector(`:scope > * :where(${selectors})`)

  if (source) {
    if (!cloned) {
      const clone = source.cloneNode(true) as HTMLElement
      // If the markup isn't fresh from the server, remove the hidden class.
      clone.classList.remove('!hidden')
      root.closest('.Popup-content')?.prepend(clone)
      if (hideOriginal) source.classList.add('!hidden')
    }
  }
}

const copyDialogAriaLabel = (content: HTMLElement | null) => {
  if (!content) return

  const legacyPopup = content.closest('.Popup')
  const dialog = content.closest('dialog[open]')
  if (!legacyPopup || !dialog) return

  const legacyAriaLabel = legacyPopup.ariaLabel
  let ariaLabel = dialog.ariaLabel
  if (ariaLabel && (!legacyAriaLabel || ariaLabel === legacyAriaLabel)) {
    legacyPopup.removeAttribute('aria-label')
    return
  }

  if (legacyAriaLabel) {
    ariaLabel = legacyAriaLabel
    legacyPopup.removeAttribute('aria-label')
  }

  // If not already present in v4, aria-label should be set to the heading copied by #renderDialogHeading
  if (!ariaLabel) {
    const widgetTitle = legacyPopup.querySelector(
      ':scope > .Popup-content > .Widget-title',
    )
    const widgetText =
      widgetTitle?.querySelector(':scope > span')?.textContent ||
      widgetTitle?.textContent ||
      null
    ariaLabel = widgetText
  }

  if (!ariaLabel) return
  dialog.ariaLabel = ariaLabel
}

/* Legacy popups which do NOT load their contents in a frame. eg: ContentSummary */
onFind(
  'btu-dialog dialog[open] .Popup-content:not(:has(> .frame))',
  (content: HTMLElement) => {
    renderDialogHeading(content, 'h1,h2,h3,.Widget-title')
    copyDialogAriaLabel(content)
  },
)

/* Legacy popups which load their contents in a frame */
onFind(
  'btu-dialog dialog[open] .Popup.loaded .frame.plugin-popup.loaded',
  async (frame: HTMLElement) => {
    /* Disabling eslint because the preceeding QS establishes that this is a descendant of a popup. */
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    await onTransitionsEnded(frame.closest('dialog[open]')!)

    // after transitions are complete, show the content.
    frame.classList.add('is-transitioned')

    // autofocus a search input if it exists (these don't reliably have an "autofocus" attribute present).
    frame
      .querySelector<HTMLInputElement>(
        '.SearchInput input:not([hidden]), [class^=Search] > btu-input input:not([hidden])',
      )
      ?.focus()

    // Clone the title from the frame to the popup.
    const blacklistedFromCloning = [
      '.SearchWidget.is-global',
      '.Widget[name="diff"]',
      '.Widget[name^="create-new-" i]',
      '.Widget[name^="objectId-" i]:not(:has(.ContentReporting-status))',
      '.Widget[name^="_copy" i]',
      '.Widget[name^="DateStringCalendarFrame" i]',
    ]
    if (!blacklistedFromCloning.some((t) => frame.matches(t))) {
      renderDialogHeading(
        frame.closest('.Popup-content'),
        'h1,h2,h3,.Guide-title,.Widget-title',
      )
      copyDialogAriaLabel(frame.closest('.Popup-content'))
    }
  },
)

/* Admin edit modals */
onFind(
  'btu-dialog dialog[open] .Popup.loaded .plugin-popup.loaded.Widget[name^="objectId-" i]:has(.Admin > :where(.Admin-nav, .Admin-main))',
  (frame: HTMLElement) => {
    renderDialogHeading(
      frame.closest('.Popup-content'),
      '.Admin .Widget-title',
      false,
    )
    copyDialogAriaLabel(frame.closest('.Popup-content'))
  },
)

/*
  Popovers and Modal Dialogs:
  Avoiding the "PrePublish" & "QueryField-frame" popup as they are special cases.
  The "PrePublishPopup" class is added after page load to trigger the auto-open behavior.
*/
onFind(
  '.Popup:not(.PrePublish, [name="queryField"]), .PrePublishPopup',
  (legacyPopup: HTMLElement) => {
    // Exit early for those which are non-modal popovers.
    if (
      legacyPopup.classList.contains('is-local') &&
      !isPopoverBlacklisted(legacyPopup)
    ) {
      legacyPopup.setAttribute('popover', '')
      legacyPopup.addEventListener('toggle', (event: any) => {
        if (event.newState === 'closed') {
          legacyPopup.dispatchEvent(
            new CustomEvent('legacy-popup-closed', {
              bubbles: true,
              composed: true,
            }),
          )
          legacyPopup.remove()
        }
      })
      legacyPopup.showPopover()
      return
    }

    // Upgrade jQuery Popups to WebComponent.
    const dialog = new Dialog()
    dialog.addEventListener('btu-dialog-ready', () => {
      onRemove.resume()
    })

    /* This is to pause any onRemove callbacks specific to popup element while it's being moved */
    onRemove.pause()

    legacyPopup.parentNode?.insertBefore(dialog, legacyPopup)
    legacyPopup.removeAttribute('role') // Native dialog element has implicit role="dialog"
    dialog.append(legacyPopup)
  },
)

/* -------------- [begin] One-off modals --------------------------- */

/* Special snowflake popup which is part of the page load and needs to immediately open as a modal. */
onFind('.PrePublish:has(.Popup-content)', async (popup: HTMLElement) => {
  setTimeout(() => popup.classList.add('PrePublishPopup'), 0)
})

/* Content edit workflow popover now opens up as a dialog modal in v5 */
onFind('.ContentEdit-workflowToggle', (toggle: HTMLElement) => {
  const workflowPopup = toggle.parentElement?.querySelector<HTMLElement>(
    '.ContentEdit-workflow',
  )
  if (!workflowPopup) return
  const dialog = new Dialog()
  dialog.removeOnClose = false
  dialog.autoOpen = false

  dialog.addEventListener('btu-dialog-ready', () => {
    onRemove.resume()
  })

  /* This is to pause any onRemove callbacks specific to popup element while it's being moved */
  onRemove.pause()

  workflowPopup.parentNode?.insertBefore(dialog, workflowPopup)
  dialog.append(workflowPopup)

  toggle.addEventListener('click', (event) => {
    if (toggle.classList.contains('is-open')) {
      dialog.show()
    } else {
      dialog.close(event)
    }
  })
})

/* Query field modal */
interface QueryInputHTMLElement extends HTMLElement {
  frame?: QueryFieldFrameElement
}

interface QueryFieldFrameElement extends HTMLElement {
  _bspDialogSetup?: boolean
}

onFind('.QueryField-toggle', (toggle: HTMLElement) => {
  const queryField =
    toggle.parentElement?.querySelector<QueryInputHTMLElement>('.QueryField')
  if (!queryField) return
  const queryFieldFrame = queryField.frame
  if (!queryFieldFrame) return

  // Multiple onFind registrations (v4/QueryField.js + v5.ts + others) each
  // have their own data-ofc key, so the same toggle can be processed N times.
  // Guard on the frame element so only the first registration sets up the Dialog.
  if (queryFieldFrame._bspDialogSetup) return
  queryFieldFrame._bspDialogSetup = true

  // Upgrade jQuery Popups to WebComponent.
  const dialog = new Dialog()
  dialog.removeOnClose = false
  dialog.autoOpen = false

  // Defer moving the frame and wiring handlers until the Popup is present.
  // Uses a brightspot-frame-load listener scoped to this specific frame rather
  // than a global onFind('.QueryField-frame > .Popup') registration, which
  // would fire for every QueryField-frame Popup on the page and cause
  // "insertBefore: new child contains the parent" errors when multiple query
  // fields exist simultaneously.
  const onFrameLoaded = () => {
    const popup = queryFieldFrame.querySelector<HTMLElement>('.Popup')
    if (!popup) return // early dispatch fired before form response; keep listening
    queryFieldFrame.removeEventListener('brightspot-frame-load', onFrameLoaded)

    dialog.addEventListener('btu-dialog-ready', () => {
      onRemove.resume()
    })

    /* This is to pause any onRemove callbacks specific to popup element while it's being moved */
    onRemove.pause()

    queryFieldFrame.parentNode?.insertBefore(dialog, queryFieldFrame)
    dialog.append(queryFieldFrame)

    toggle.addEventListener('click', (event) => {
      dialog.show()
    })

    dialog.addEventListener('btu-dialog-closed', (event) => {
      popup.classList.remove('is-visible')
    })

    const saveButton =
      queryFieldFrame.querySelector<HTMLElement>('.QueryField-button')
    if (!saveButton) return
    saveButton.addEventListener('click', (event) => {
      dialog.close(event)
    })
  }

  queryFieldFrame.addEventListener('brightspot-frame-load', onFrameLoaded)
})

/* Color picker modals */
onFind('dialog btu-input > .sp-replacer', (trigger: HTMLElement) => {
  const popoverId = trigger.getAttribute('aria-controls')
  if (!popoverId) return
  const colorPickerPopup = document.querySelector<HTMLElement>(`#${popoverId}`)
  if (!colorPickerPopup) return
  colorPickerPopup.setAttribute('popover', '')
  trigger.addEventListener('click', () => {
    colorPickerPopup.showPopover()
  })
})

/*
  ProseMirror spellcheck suggestions opens as a popover so that it can
  appear above fullscreen RTE.
*/
onFind(
  '.ProseMirror-spellcheck-suggestions.is-opening',
  (spellcheck: HTMLElement) => {
    spellcheck.setAttribute('popover', '')
    spellcheck.showPopover()
  },
)

/* -------------- [end] One-off modals --------------------------- */

/*
  Adds light-dismiss functionality to dialog elements.
  At the time of this implementation <dialog>'s `closedBy` attribute is experimental.
  TODO: in the future this could be simplified to use the native `closedby="any"` attribute.
  See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/closedBy
*/
document.addEventListener('click', (e) => {
  const dialog = e.target
  if (dialog instanceof HTMLDialogElement && dialog.nodeName === 'DIALOG') {
    const wrapper = dialog.parentElement
    if (wrapper && wrapper.tagName === Dialog.tagName) {
      // We don't want the following dialog classes/variants to close on light-dismiss.
      const preventLightDismissal = wrapper.querySelector(
        '.Popup.is-fromTop, .Popup.is-noGap',
      )
      if (preventLightDismissal) return
      const d = wrapper as Dialog
      d.close(e)
    }
  }
})

// Add support for legacy jQuery popup close events. (this can be removed when jQuery is removed).
document.addEventListener('legacy.popup.close', (e: Event) => {
  const popup = e.target
  if (popup instanceof HTMLElement) {
    const d = popup.closest('btu-dialog') as Dialog
    d?.close(e)
  }
})

document.addEventListener('page-nav-rail-toggled', (e: Event) => {
  // Close search dialog if it's open.
  const btuDialog = document.querySelector<Dialog>(
    'btu-dialog:has(dialog[open] .Popup[name="miscSearch"])',
  )
  btuDialog?.closest('.Page')?.classList.remove('is-searchOpening')
  btuDialog?.close(e)
})

// In month view on a calendar widget,
// clicking anywhere on a day with an event should click the event
onFind('.Month-day:has(a.calendarEvents)', (day) => {
  day.addEventListener('click', () => {
    day.querySelector<HTMLAnchorElement>('a.calendarEvents')?.click()
  })
})

// Set aria-expanded="true" only when the Ask AI panel is loaded and visible
function setAskAiButtonExpanded(expanded: boolean) {
  const btn = document.querySelector('.Page-askAiButton')
  if (btn) btn.setAttribute('aria-expanded', expanded ? 'true' : 'false')
}

// Shared click handler for .AskAi-close
function handleAskAiCloseClick(evt: Event) {
  const target = evt.target
  if (
    target instanceof HTMLElement &&
    target.classList.contains('AskAi-close')
  ) {
    // Update the container state to not loaded when it's closed.
    // This is helpful for other components to know that Ask AI panel is not active.
    const askAiContainer = document.getElementById('askAi-container')
    askAiContainer?.classList.remove('is-loaded')

    setAskAiButtonExpanded(false)
  }
}

// When Ask AI container is loaded
onFind('#askAi-container .frame.loaded', (container) => {
  container.parentElement?.classList.add('is-loaded')
  setAskAiButtonExpanded(true)

  const askAiPanel = container.closest('.AskAi')
  const targetElement = askAiPanel || container
  targetElement.addEventListener('click', handleAskAiCloseClick)
})

// Fallback: If AskAi panel appears separately
onFind('.AskAi', (container) => {
  container.addEventListener('click', handleAskAiCloseClick)
})

onFind('.Page-header .Page-askAiLink', (magicButton) => {
  /* V4 style collision without removing this class...*/
  magicButton.classList.remove('IconButton')
  onFind('.Page-header + btu-nav-rail', (navRail: HTMLElement) => {
    const magicItem = document.createElement('li')
    magicItem.appendChild(magicButton)
    const guideItem = navRail.querySelector('li:has(> .Page-guideLink)')
    /* If the guide item exists, insert the magic item before it.
       Otherwise, insert it after the first item in the nav rail. */
    if (guideItem) {
      navRail.querySelector('ul')?.insertBefore(magicItem, guideItem)
    } else {
      navRail
        .querySelector('ul > li')
        ?.insertAdjacentElement('afterend', magicItem)
    }
  })
})

// Add scroll listener for content edit mobile to allow header to collapse
onFind(
  '.Page-header.mobile ~ .Page-container:not(:has(.ContentEdit-right.is-visible)) .ContentEdit-left',
  (contentEdit) => {
    const mobileHeader = document.querySelector<HTMLElement>(
      '.Page-header.mobile',
    )
    if (!mobileHeader) return

    contentEdit.addEventListener(
      'scroll',
      throttle(100, () => {
        const scrollTop = contentEdit.scrollTop
        // Only collapse the header if the total scroll capacity exceeds the header height (plus the remove threshold).
        const maxScroll = contentEdit.scrollHeight - contentEdit.clientHeight
        if (
          scrollTop > 10 &&
          maxScroll > mobileHeader.offsetHeight + 10 &&
          !mobileHeader.classList.contains('is-scrolled')
        ) {
          mobileHeader.classList.add('is-scrolled')
        } else if (
          scrollTop <= 10 &&
          mobileHeader.classList.contains('is-scrolled')
        ) {
          mobileHeader.classList.remove('is-scrolled')
        }
      }),
    )
  },
)

// On the Reports Page, click on a report and go on the recent reports tab.
// The view icon in the content selectors should not open the status popup in fullscreen in this case.
// This removes the data for popup mode, where ContentSelector-edit usually has noGap set.
onFind(
  [
    '.ContentReportingWidget .CIG-row[data-field="getPastReportResults"] .ContentSelector-edit',
    '.CollectionList-item-edit.ContentSelector-edit',
  ],
  (viewButton) => {
    viewButton.removeAttribute('data-popup-mode')
  },
)

onFind('.Popup[name="siteSwitch"]', (el) => {
  document.getElementById('SiteSwitch-link')?.classList.add('is-active')
})

onFind('.LiveBlogPostList', (el) => {
  document.body.classList.add('has-liveBlog-postList')
})

onFind('.ToolUserAvatar', (avatar) => {
  avatar.parentElement?.classList.add('ToolUserAvatar-container')
})

document.addEventListener('legacy-popup-closed', (event) => {
  if (event.target instanceof HTMLElement) {
    if (event.target.getAttribute('name') === 'siteSwitch') {
      document.getElementById('SiteSwitch-link')?.classList.remove('is-active')
    }
  }
})

/**
 * Prevents accidental button clicks when workflow is in a dragging state.
 * Ex. if you start dragging on the "Add transition" button, it shouldn't
 * also trigger a click.
 */
onFind('.Workflow-state', (state) => {
  state.addEventListener(
    'click',
    (e) => {
      if (state.classList.contains('is-dragging')) {
        e.stopPropagation()
      }
    },
    true,
  )
})

/**
 * Auto-discover focus regions from backend-rendered [data-focus-region] elements.
 *
 * This allows Java developers to register custom focus regions purely through
 * HTML attributes, without writing any TypeScript. The element must also have
 * proper a11y attributes (role="region", aria-label) for skip-link support.
 *
 * Intentionally placed before imperative registrations — onFind callbacks fire
 * in registration order, so auto-discovery runs first and imperative register()
 * calls below will overwrite it, giving imperative registrations precedence.
 */
onFind(FOCUS_REGION_SELECTOR, (element: HTMLElement) => {
  focusRegionManager.registerFromAttribute(element)
})

/**
 * Make various a11y-related adjustments to the Page Guide region.
 */
onFind('.Page-guide', (guide: HTMLElement) => {
  const page = guide.closest<HTMLElement>('.Page')
  if (!page) return

  // Move the guide before the Page-container for better reading order
  const pageContainer = page?.querySelector('.Page-container')
  if (pageContainer) {
    pageContainer.parentNode?.insertBefore(guide, pageContainer)
  }

  focusRegionManager.register({
    id: RegionId.PageGuide,
    element: guide,
    isHidden: () => !page.classList.contains('is-guideOpen'),
  })

  function assignRegionLabel() {
    const title = guide.querySelector<HTMLElement>('.Guide-title-text')
    if (title) {
      title.id = 'Guide-title'
      guide.setAttribute('aria-labelledby', 'Guide-title')
    } else {
      guide.removeAttribute('aria-labelledby')
    }
  }

  const guideVisibilityObserver = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
      if (mutation.type === 'childList') {
        assignRegionLabel()
      }
    })
  })

  assignRegionLabel()
  guideVisibilityObserver.observe(guide, {
    childList: true,
    subtree: true,
  })
})

/**
 * Register the Page Header region.
 */
onFind('.Page-header', (header: HTMLElement) => {
  // Note that <header> implicitly has role="banner"
  focusRegionManager.register({
    id: RegionId.PageHeader,
    element: header,
    label: window.BRIGHTSPOT?.ui.tooltips.skipLinks.pageHeader,
  })
})

/**
 * Register the Nav Rail region.
 */
onFind('btu-nav-rail[role="navigation"]', (navRail: HTMLElement) => {
  focusRegionManager.register({
    id: RegionId.PageNav,
    element: navRail,
    label: window.BRIGHTSPOT?.ui.tooltips.skipLinks.pageNavigation,
  })
})

/**
 * Register the main region for non-admin & non-content edit pages.
 */
onFind(
  '.Page-content:not(:has(> .content-edit), :has(> .Admin))',
  (content: HTMLElement) => {
    focusRegionManager.register({
      id: RegionId.PageMain,
      element: content,
      label: window.BRIGHTSPOT?.ui.tooltips.skipLinks.mainContent,
    })
  },
)

/**
 * Register popup/modal header region so that focus can be directed to the close button.
 */
onFind(
  'dialog[open] .Popup-content > .Popup-close',
  (popupContent: HTMLElement) => {
    focusRegionManager.register({
      id: RegionId.ModalClose,
      element: popupContent,
    })
  },
)

/**
 * Register content edit widget regions (ContentEdit-right).
 * Also set preferred focus to the text input when Conversations widget is active.
 * Uses MutationObserver to re-register when visibility changes.
 */
onFind('.ContentEdit-right[role="region"]', (rightPanel: HTMLElement) => {
  const registerPanel = () => {
    const conversationPreferredFocus =
      rightPanel.dataset.rightTab === 'chat_bubble'

    focusRegionManager.register({
      id: RegionId.ContentEditRight,
      element: rightPanel,
      preferredFocus: () => {
        if (!conversationPreferredFocus) return null
        return rightPanel.querySelector<HTMLElement>(
          '.Conversation-newPost .ProseMirror',
        )
      },
      isHidden: () => !rightPanel.classList.contains('is-visible'),
    })
  }

  // Initial registration
  if (rightPanel.classList.contains('is-visible')) registerPanel()

  // Watch for visibility changes and re-register when .is-visible changes
  const observer = new MutationObserver((mutations) => {
    for (const mutation of mutations) {
      if (rightPanel.classList.contains('is-visible')) {
        registerPanel()
      }
    }
  })

  observer.observe(rightPanel, {
    attributeFilter: ['class'],
  })
})

/**
 * Register the Content Edit toolbar region.
 */
onFind('section > .ContentEdit-toolbar', (toolbar: HTMLElement) => {
  focusRegionManager.register({
    id: RegionId.ContentEditToolbar,
    element: toolbar,
  })
})

/**
 * Register the Content Edit main (left) region.
 */
onFind('.ContentEdit-left[role="region"]', (mainPanel: HTMLElement) => {
  focusRegionManager.register({
    id: RegionId.PageMain,
    element: mainPanel,
  })
})

/**
 * Register the Content Edit title (top) region.
 */
onFind('.ContentEdit-top[role="region"]', (titlePanel: HTMLElement) => {
  focusRegionManager.register({
    id: RegionId.ContentEditTop,
    element: titlePanel,
  })
})

/**
 * Register the Content Edit preview region.
 */
onFind(
  '.ContentEdit-preview[role="region"]:not([inert])',
  (previewPanel: HTMLElement) => {
    focusRegionManager.register({
      id: RegionId.ContentEditPreview,
      element: previewPanel,
      isHidden: () => previewPanel.hasAttribute('inert'),
    })
  },
)

/**
 * Register the Content Edit drawer region.
 */
onFind('.ContentEditDrawer[role="region"].is-open', (drawer: HTMLElement) => {
  focusRegionManager.register({
    id: RegionId.ContentEditDrawer,
    element: drawer,
    isHidden: () => !drawer.classList.contains('is-open'),
  })
})

/**
 * Duplicate id management for situations when multiple content edit forms are open at once.
 */
/* Generate unique IDs for ContentEditDrawer titles */
onFind('#ContentEditDrawer-title', (title: HTMLElement) => {
  const drawer = title.closest<HTMLElement>('.ContentEditDrawer[role="region"]')
  if (!drawer) return

  const uniqueId = `ContentEditDrawer-title-${uuid().replace(/-/g, '')}`
  title.id = uniqueId
  drawer.setAttribute('aria-labelledby', uniqueId)
})

/* Generate unique IDs for ContentEdit widget titles */
onFind('.ContentEdit-right-title', (title: HTMLElement) => {
  const panel = title.closest<HTMLElement>('[role="region"][data-right-tab]')
  if (!panel) return

  const baseId = title.id
  const uniqueId = `${baseId}-${uuid().replace(/-/g, '')}`
  title.id = uniqueId
  panel.setAttribute('aria-labelledby', uniqueId)
})

/**
 * Register the Admin left & main regions. Also ensures that the main region
 * has an identifiable label.
 */
onFind('.Admin-nav[role="navigation"]', (adminNav: HTMLElement) => {
  // Resolve the admin page name from the aria-labelledby heading (e.g., "Plugins")
  // and construct a label like "Plugins Navigation"
  const labelledById = adminNav.getAttribute('aria-labelledby')
  const pageName = labelledById
    ? document.getElementById(labelledById)?.textContent?.trim()
    : null
  const navLabel = pageName
    ? `${pageName} ${window.BRIGHTSPOT?.ui.tooltips.skipLinks.navigation}`
    : window.BRIGHTSPOT?.ui.tooltips.skipLinks.navigation

  focusRegionManager.register({
    id: RegionId.LeftNav,
    element: adminNav,
    label: navLabel,
  })

  // Register the Admin main region, if it exists.
  const adminMain = adminNav.nextElementSibling as HTMLElement
  if (!adminMain || !adminMain.classList.contains('Admin-main')) return

  focusRegionManager.register({
    id: RegionId.PageMain,
    element: adminMain,
    label: window.BRIGHTSPOT?.ui.tooltips.skipLinks.mainContent,
  })

  // Assign an id to the main region label if one is not already present.
  const labelId = adminMain.getAttribute('aria-labelledby')
  if (!labelId) return
  if (document.getElementById(labelId)) return
  adminMain
    .querySelector<HTMLElement>('.Widget-title')
    ?.setAttribute('id', labelId)
})

/**
 * Ensures focused elements are scrolled into view with proper offset
 * to account for sticky ActionBar at the bottom of scrollable containers.
 * Only scrolls when focus arrives via keyboard navigation, not mouse/touch clicks,
 * to avoid interfering with dropdown positioning and click actions.
 */
onFind('.ActionBar', (actionBar) => {
  const widget = actionBar.closest('.Widget')
  if (!widget) return
  const widgetTitle = widget.querySelector<HTMLElement>('.Widget-title')

  let lastPointerType = ''
  const handlePointerdown = (event: Event) => {
    lastPointerType = (event as PointerEvent).pointerType
    // Reset after current task so only the immediately following focusin is affected.
    setTimeout(() => {
      lastPointerType = ''
    }, 0)
  }

  const handleFocusin = (event: Event) => {
    // Skip scroll-into-view when focus is triggered by mouse or touch click —
    // smooth scrolling mid-click disrupts dropdown positioning and button actions.
    if (lastPointerType) return

    const target = event.target
    if (!(target instanceof HTMLElement)) return

    requestAnimationFrame(() => {
      const targetRect = target.getBoundingClientRect()
      if (
        targetRect.bottom > actionBar.getBoundingClientRect().top ||
        targetRect.top < (widgetTitle?.getBoundingClientRect().bottom || 0)
      ) {
        target.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
      }
    })
  }

  widget.addEventListener('pointerdown', handlePointerdown, { passive: true })
  widget.addEventListener('focusin', handleFocusin, { passive: true })
  onRemove(widget, () => {
    widget.removeEventListener('pointerdown', handlePointerdown)
    widget.removeEventListener('focusin', handleFocusin)
  })
})
