-- Hyperscript helpers for the post-#140 batched-save capability transfer pane.
--
-- The widget wraps its contents in a <form>. Each capability has a server-
-- rendered Available row and zero-or-more Assigned rows; the Assigned rows
-- are the source of truth for the desired-state set submitted on Save.
--
-- Symmetric pending-state model: every staged change is visualized on BOTH
-- columns simultaneously, so the user sees what's moving where:
--
--   ADD (click + on Available cap X):
--     Available row X     → pending-remove styling (leaving Available)
--     Assigned new row    → pending-add styling   (arriving in Assigned)
--
--   REMOVE (click × on Assigned cap Y):
--     Assigned row Y      → pending-remove styling (leaving Assigned)
--     Available row Y     → pending-add styling   (arriving in Available)
--
-- The four resolved states for an Available row, computed by
-- empireTransferRefreshFilter on every state change:
--
--   saved + no pending:     hidden                  (already-assigned, normal)
--   saved + pending_remove: visible, .pending-add   (will arrive after Save)
--   not saved + no pending: visible, no class       (assignable, normal)
--   not saved + pending_add:visible, .pending-remove(will leave after Save)
--
-- Saved-status is encoded server-side: data-saved="true" on non-scope rows
-- whose cap is in the saved Assigned set; data-assigned-tuples on every
-- scope-mode row (semicolon-delimited "scope|division" list of saved tuples).
--
-- The behavior `empireTransferBuffer` is installed on the form via
-- `data-script="install empireTransferBuffer"`. It uses click-target matching
-- (one listener on the form) so dynamically-added pending-add Assigned rows
-- pick up handlers for free.

-- Keep the division <select> disabled unless the scope is 'div'. The server
-- rejects `(scope!='div', division=X)` combinations; disabling the control
-- prevents the user from submitting one in the first place.
def empireCapabilityTransferPickerSyncDivisionDisabled(picker)
  if no picker then exit end
  set scopeSel to the first <[name='scope_kind']/> in picker
  set divSel to the first <[name='division_id']/> in picker
  if no scopeSel or no divSel then exit end
  if scopeSel's value is 'div'
    remove @disabled from divSel
  else
    set divSel's value to ''
    add @disabled to divSel
  end
end

-- Tag the picker's tuple as valid/invalid + propagate to + buttons.
-- `(scope=div, division='')` is invalid; everything else is valid.
def empireCapabilityTransferRefreshTupleValidity(picker)
  if no picker then exit end
  set scopeKind to (the first <[name='scope_kind']/> in picker)'s value
  set divisionId to (the first <[name='division_id']/> in picker)'s value
  set valid to true
  if scopeKind is 'div' and divisionId is ''
    set valid to false
  end
  if valid
    set picker's @data-tuple-valid to 'true'
  else
    set picker's @data-tuple-valid to 'false'
  end
  set form to closest <form.empire-widget-transfer-box/> to picker
  if no form then exit end
  for btn in <[data-action='add']/> in form
    if valid
      remove @disabled from btn
    else
      add @disabled to btn
    end
  end
end

-- Wrapper that re-runs the dynamic filter from the picker partial's
-- onchange/onload chain. Existing entry-point name; kept stable.
def empireCapabilityTransferFilter(box)
  call empireTransferRefreshFilter(box)
end

-- Compute the desired_value an Available row would carry if staged as ADD,
-- given the current picker tuple. For non-scope mode this is just the
-- cap_id; for scope mode it's `cap_id|scope_kind|division_id`.
def empireTransferAvailableKey(form, row)
  set capId to row's @data-cap-id
  if form's @data-scope-mode is 'true'
    set picker to the first <[id$='-scope-picker']/> in form
    if no picker then return capId end
    set scopeKind to (the first <[name='scope_kind']/> in picker)'s value
    set divisionId to (the first <[name='division_id']/> in picker)'s value
    return capId + '|' + scopeKind + '|' + divisionId
  else
    return capId
  end
end

-- + on an Available row: stage the cap as pending_add by appending a new
-- Assigned row with pending-add styling. RefreshFilter then re-styles the
-- Available row as pending-remove (the symmetric staged-pair view).
def empireTransferOnAdd(form, button)
  if no form then exit end
  set row to closest .empire-widget-transfer-item to button
  if no row then exit end
  set capId to row's @data-cap-id
  set label to row's @data-label
  set scopeMode to (form's @data-scope-mode is 'true')

  if scopeMode
    set picker to the first <[id$='-scope-picker']/> in form
    if no picker then exit end
    if picker's @data-tuple-valid is not 'true' then exit end
    set scopeKind to (the first <[name='scope_kind']/> in picker)'s value
    set divisionId to (the first <[name='division_id']/> in picker)'s value
    set desiredValue to capId + '|' + scopeKind + '|' + divisionId
  else
    set desiredValue to capId
  end

  -- Collapse-on-re-add: if a pending_remove Assigned row exists for this
  -- desired_value, revert it instead of creating a new pending_add.
  set selector to '[data-desired-value="' + desiredValue + '"][data-pending-state="pending_remove"]'
  set existing to form.querySelector(selector)
  if existing
    call empireTransferRevertAssignedRow(existing)
    call empireTransferRefreshFilter(form)
    call empireTransferRecomputeDirty(form)
    exit
  end

  -- Build a new pending-add Assigned row.
  set list to the first <[data-role='assigned-list']/> in form
  if no list then exit end

  set newRow to document.createElement('div')
  set newRow's className to 'empire-widget-transfer-item pending-add'
  newRow.setAttribute('data-desired-value', desiredValue)
  newRow.setAttribute('data-pending-state', 'pending_add')

  set hiddenInput to document.createElement('input')
  set hiddenInput's type to 'hidden'
  set hiddenInput's name to 'desired'
  set hiddenInput's value to desiredValue
  newRow.appendChild(hiddenInput)

  set labelSpan to document.createElement('span')
  set codeEl to document.createElement('code')
  set codeEl's textContent to label
  labelSpan.appendChild(codeEl)

  -- In scope mode, mirror the server-rendered tag pills so the user can tell
  -- pending rows apart when the same capability is added at multiple
  -- (scope_kind, division_id) tuples.
  if scopeMode
    set scopeTag to document.createElement('span')
    set scopeTag's className to 'tag small empire-widget-transfer-item-tag'
    set scopeTag's textContent to scopeKind
    labelSpan.appendChild(scopeTag)
    if divisionId is not ''
      set divSelectEl to the first <[name='division_id']/> in picker
      set divName to ''
      if divSelectEl
        set selectedOpt to divSelectEl.options[divSelectEl.selectedIndex]
        if selectedOpt then set divName to selectedOpt's textContent end
      end
      if divName is not ''
        set divTag to document.createElement('span')
        set divTag's className to 'tag small light empire-widget-transfer-item-tag'
        set divTag's textContent to divName
        labelSpan.appendChild(divTag)
      end
    end
  end

  newRow.appendChild(labelSpan)

  set revertBtn to document.createElement('button')
  set revertBtn's type to 'button'
  set revertBtn's className to 'button small light'
  revertBtn.setAttribute('data-action', 'revert')
  revertBtn.setAttribute('title', 'Revert')
  revertBtn.setAttribute('aria-label', 'Revert capability ' + label)
  set revertBtn's innerHTML to '&#8634;'
  newRow.appendChild(revertBtn)

  list.appendChild(newRow)

  call empireTransferRefreshFilter(form)
  call empireTransferRecomputeDirty(form)
end

-- × on a saved Assigned row: flip to pending_remove styling, disable the
-- hidden input, swap the × button to ↶ (action / class / title / aria-label).
-- RefreshFilter then re-styles the matching Available row as pending-add.
def empireTransferOnRemove(row)
  if no row then exit end
  add .pending-remove to row
  set row's @data-pending-state to 'pending_remove'
  set input to the first <input[name='desired']/> in row
  if input then add @disabled to input end
  set btn to the first <[data-action='remove']/> in row
  if btn
    -- Swap the entire button presentation: action, classes (drop danger),
    -- title, aria-label (preserve the cap label), and glyph.
    set btn's @data-action to 'revert'
    btn.classList.remove('danger')
    set btn's @title to 'Revert'
    set capLabel to ''
    set codeEl to the first <code/> in row
    if codeEl then set capLabel to codeEl's textContent end
    if capLabel is not ''
      set btn's @aria-label to 'Revert capability ' + capLabel
    else
      set btn's @aria-label to 'Revert'
    end
    set btn's innerHTML to '&#8634;'
  end
end

-- Internal helper: revert a pending Assigned row back to its prior state.
-- pending_add → row removed from DOM (no saved version existed).
-- pending_remove → row reverted to saved styling, hidden input re-enabled,
-- button swapped from ↶ back to ×.
def empireTransferRevertAssignedRow(row)
  if no row then exit end
  set state to row's @data-pending-state
  if state is 'pending_add'
    remove row
  else if state is 'pending_remove'
    remove .pending-remove from row
    set row's @data-pending-state to 'saved'
    set input to the first <input[name='desired']/> in row
    if input then remove @disabled from input end
    set btn to the first <[data-action='revert']/> in row
    if btn
      set btn's @data-action to 'remove'
      btn.classList.add('danger')
      set btn's @title to 'Remove'
      set capLabel to ''
      set codeEl to the first <code/> in row
      if codeEl then set capLabel to codeEl's textContent end
      if capLabel is not ''
        set btn's @aria-label to 'Remove capability ' + capLabel
      else
        set btn's @aria-label to 'Remove'
      end
      set btn's innerHTML to '&times;'
    end
  end
end

-- ↶ on either side of a staged pair: dispatches to the matching Assigned
-- row and reverts it. RefreshFilter then re-styles both columns.
--
-- Assigned-side ↶: act on the clicked row directly.
-- Available-side ↶: compute the key (cap_id, or cap_id|picker-tuple in
--   scope mode), find the Assigned pending_add or pending_remove row with
--   that desired_value, and act on it.
def empireTransferOnRevert(form, row)
  if no form or no row then exit end
  set assignedList to the first <[data-role='assigned-list']/> in form
  if assignedList and assignedList.contains(row)
    call empireTransferRevertAssignedRow(row)
  else
    set key to empireTransferAvailableKey(form, row)
    set selector to '[data-desired-value="' + key + '"]'
    set assignedRow to assignedList.querySelector(selector)
    if assignedRow then call empireTransferRevertAssignedRow(assignedRow) end
  end
  call empireTransferRefreshFilter(form)
  call empireTransferRecomputeDirty(form)
end

-- Resolve every Available row to one of the four states based on saved-
-- status (data-saved / data-assigned-tuples) and the live pending sets
-- harvested from the Assigned column. Toggles .hidden, .pending-add,
-- .pending-remove on the row, and swaps the row's button between + (data-
-- action='add') and ↶ (data-action='revert') so the click targets the
-- right handler. Also recomputes accordion group count badges.
def empireTransferRefreshFilter(form)
  if no form then exit end
  set scopeMode to (form's @data-scope-mode is 'true')
  set pendingAdd to []
  set pendingRemove to []
  for row in <[data-role='assigned-list'] .empire-widget-transfer-item/> in form
    set state to row's @data-pending-state
    if state is 'pending_add'
      append row's @data-desired-value to pendingAdd
    else if state is 'pending_remove'
      append row's @data-desired-value to pendingRemove
    end
  end

  set tuple to ''
  if scopeMode
    set picker to the first <[id$='-scope-picker']/> in form
    if picker
      set scopeKind to (the first <[name='scope_kind']/> in picker)'s value
      set divisionId to (the first <[name='division_id']/> in picker)'s value
      set tuple to scopeKind + '|' + divisionId
    end
  end

  set assignedList to the first <[data-role='assigned-list']/> in form
  for item in <.empire-widget-transfer-item[data-cap-id]/> in form
    -- Skip items inside the Assigned list (this loop is Available-only).
    if no assignedList or not assignedList.contains(item)
      set capId to item's @data-cap-id
      if scopeMode
        set key to capId + '|' + tuple
        set savedTuples to (item's @data-assigned-tuples or '').split(';')
        set saved to savedTuples.includes(tuple)
      else
        set key to capId
        set saved to (item's @data-saved is 'true')
      end

      remove .hidden from item
      remove .pending-add from item
      remove .pending-remove from item

      set inPendingAdd to pendingAdd.includes(key)
      set inPendingRemove to pendingRemove.includes(key)

      if inPendingAdd
        -- Available row is the SOURCE of a staged ADD: leaving Available.
        add .pending-remove to item
      else if inPendingRemove
        -- Available row is the DESTINATION of a staged REMOVE: arriving.
        add .pending-add to item
      else if saved
        add .hidden to item
      end

      -- Swap the row's button between + (no pending) and ↶ (pending state)
      -- so the click targets the right handler. data-action drives
      -- empireTransferBuffer's event delegation.
      set btn to the first <button/> in item
      if btn
        set capLabel to item's @data-label
        if inPendingAdd or inPendingRemove
          set btn's @data-action to 'revert'
          btn.classList.remove('primary')
          set btn's @title to 'Revert'
          set btn's @aria-label to 'Revert capability ' + capLabel
          set btn's innerHTML to '&#8634;'
        else
          set btn's @data-action to 'add'
          btn.classList.add('primary')
          set btn's @title to 'Add'
          set btn's @aria-label to 'Add capability ' + capLabel
          set btn's innerHTML to '+'
        end
      end
    end
  end

  -- Update each accordion group's count badge to reflect visible (non-hidden)
  -- items. The accordion macro renders the count from items|length at render
  -- time; this loop reconciles to the actual visible state after filtering.
  for itemsBox in <.empire-widget-accordion-items/> in form
    set visible to 0
    for item in <.empire-widget-transfer-item/> in itemsBox
      if item.parentElement is itemsBox and not item.classList.contains('hidden')
        increment visible
      end
    end
    set toggle to itemsBox.previousElementSibling
    if toggle
      set countTag to the first <.tag/> in toggle
      if countTag then set countTag's textContent to visible end
    end
  end
end

-- Count pending changes; toggle Save (enabled when dirty), show Cancel,
-- update the count label and the Assigned-heading badge, set form's
-- data-dirty for beforeunload. The badge reflects post-Save state
-- (saved + pending_add, excluding pending_remove) so users see what
-- they'll commit, not the stale server-rendered count. Also toggles the
-- empty-message div based on the post-Save count.
def empireTransferRecomputeDirty(form)
  if no form then exit end
  -- Count pending changes from the Assigned column only. The symmetric
  -- model also tags the mirrored Available row with .pending-add /
  -- .pending-remove, so a global class scan would double-count.
  set count to 0
  set assignedCount to 0
  set rawRowCount to 0
  for row in <[data-role='assigned-list'] .empire-widget-transfer-item/> in form
    increment rawRowCount
    set state to row's @data-pending-state
    if state is 'pending_add' or state is 'pending_remove'
      increment count
    end
    if state is not 'pending_remove'
      increment assignedCount
    end
  end
  set assignedBadge to the first <[data-role='assigned-count']/> in form
  if assignedBadge then set assignedBadge's textContent to assignedCount end

  -- Empty-message visibility tracks raw DOM rows in the assigned-list, not
  -- post-save count: a pending_remove row is still in the DOM (visible
  -- with strikethrough), so the message should stay hidden.
  set empty to the first <[data-role='empty-message']/> in form
  if empty
    if rawRowCount > 0
      add .hidden to empty
    else
      remove .hidden from empty
    end
  end

  set save to the first <[data-action='save']/> in form
  set cancel to the first <[data-action='cancel']/> in form
  set countLabel to the first <[data-role='pending-count']/> in form
  if count > 0
    if save then remove @disabled from save end
    if cancel then remove @hidden from cancel end
    if countLabel
      set noun to ' changes'
      if count is 1 then set noun to ' change' end
      set countLabel's textContent to '(' + count + noun + ')'
      remove @hidden from countLabel
    end
    set form's @data-dirty to 'true'
  else
    if save then add @disabled to save end
    if cancel then add @hidden to cancel end
    if countLabel then add @hidden to countLabel end
    set form's @data-dirty to 'false'
  end
end

behavior empireTransferBuffer
  on click
    if target matches '[data-action="add"]'
      halt the event
      call empireTransferOnAdd(me, target)
    else if target matches '[data-action="remove"]'
      halt the event
      set row to closest .empire-widget-transfer-item to target
      call empireTransferOnRemove(row)
      call empireTransferRefreshFilter(me)
      call empireTransferRecomputeDirty(me)
    else if target matches '[data-action="revert"]'
      halt the event
      set row to closest .empire-widget-transfer-item to target
      call empireTransferOnRevert(me, row)
    end
  -- Save submits the form. Mark a transient ``data-submitting`` flag so the
  -- beforeunload guard skips during the in-flight HTMX request. HTMX
  -- preventDefaults the submit (no real navigation), but some browsers fire
  -- beforeunload during the submit-then-swap window. On a successful swap
  -- the form is replaced wholesale and the flag is gone with it; on a
  -- request error the form stays and ``htmx:afterRequest`` re-arms the
  -- guard so the user's still-buffered changes are protected.
  on submit
    set me's @data-submitting to 'true'
  on htmx:afterRequest from me
    remove @data-submitting from me
  on beforeunload from window
    if me.dataset.dirty is 'true' and me.dataset.submitting is not 'true'
      halt the event
      set event.returnValue to ''
    end
end
