components_data_jellyfin_JellyfinUserSettings.bs

import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/misc.bs"

sub init()
  m.log = log.Logger("JellyfinUserSettings")
  m.log.info("Initializing JellyfinUserSettings node")

  ' Track previous state for change detection
  m.previousDisplaySettings = {}
end sub

' Enable automatic syncing of settings changes to registry
' Call this AFTER loading initial settings from registry/server
' This starts field observers that write changes back to registry
sub enableAutoSync()
  m.log.info("Enabling auto-sync - setting up field observers")

  ' Capture current displaySettings state BEFORE enabling observers
  ' This prevents initial observer fire from writing to registry
  if isValid(m.top.displaySettings)
    m.previousDisplaySettings = deepCopyAA(m.top.displaySettings)
  end if

  ' Set up observers for all settings fields
  observeAllSettings()

  m.log.info("Auto-sync enabled - settings changes will be saved to registry")
end sub

' Disable automatic syncing (optional, for cleanup or testing)
sub disableAutoSync()
  m.log.info("Disabling auto-sync - removing field observers")

  ' Get all fields to unobserve
  allFields = m.top.getFields()

  ' Fields that were excluded from observing
  excludedFields = [
    "id",
    "isLoaded",
    "loadedAt",
    "rawSettings"
  ]

  ' Remove observers from all observed fields
  for each fieldName in allFields
    if not inArray(excludedFields, fieldName)
      m.top.unobserveField(fieldName)
    end if
  end for

  m.log.info("Auto-sync disabled")
end sub

' Dynamically set up observers for all user setting fields
' This automatically handles new fields added to the XML without code changes
sub observeAllSettings()
  ' IMPORTANT: displaySettings must be observed explicitly
  m.top.observeField("displaySettings", "onDisplaySettingsChanged")

  ' Get ALL field names defined in the XML interface (not just fields with values)
  allFieldNames = m.top.getFields()

  ' Fields that should NOT be observed (internal/system fields)
  excludedFields = [
    "id",
    "isLoaded",
    "loadedAt",
    "rawSettings", ' Debug-only field, don't observe
    "displaySettings" ' Already observed above with custom handler
  ]

  ' Loop through all field names
  for each fieldName in allFieldNames
    ' Skip excluded fields
    if inArray(excludedFields, fieldName)
      continue for
    end if

    ' All other fields use the standard handler
    m.top.observeField(fieldName, "onSettingChanged")
  end for
end sub

' Called when any individual setting field changes
' Observer is the single source of truth for registry sync
' IMPORTANT: Only syncs to registry - does NOT modify the node (prevents recursion)
'
' DESIGN: Always save settings to registry, never delete
' This preserves user intent - if user explicitly sets a value to match the default,
' that's different from "never touched this setting". This is important for:
' - Future multi-device sync
' - Understanding what users have customized
' - "Reset all settings" feature can delete entire registry section
sub onSettingChanged(event as object)
  fieldName = event.getField()
  newValue = event.getData()

  ' Route to appropriate registry section based on setting type
  if isGlobalSetting(fieldName)
    ' Global settings apply to all users - save to "JellyRock" or "test-global" section
    globalSection = getGlobalRegistrySection()
    registry_write(fieldName, valueToString(newValue), globalSection)
  else
    ' User-specific settings - save to user's section
    ' Get local reference to minimize rendezvous
    localUser = m.global.user
    if not isValid(localUser.id)
      m.log.warn("Cannot sync setting to registry - user ID is invalid", { field: fieldName })
      return
    end if

    ' Always save to registry - track all user changes
    registry_write(fieldName, valueToString(newValue), localUser.id)
  end if
end sub

' Called when displaySettings field changes
' Only syncs the specific settings that changed, not the entire object
sub onDisplaySettingsChanged()
  newDisplaySettings = m.top.displaySettings

  if not isValid(newDisplaySettings)
    m.log.warn("onDisplaySettingsChanged called but newDisplaySettings is invalid")
    return
  end if

  ' Compare with previous state to find what changed BEFORE updating previous state
  syncDisplaySettingsChanges(m.previousDisplaySettings, newDisplaySettings)

  ' Update previous state for next comparison
  m.previousDisplaySettings = deepCopyAA(newDisplaySettings)
end sub

' Sync only the display settings that changed
sub syncDisplaySettingsChanges(oldSettings as object, newSettings as object)
  if not isValid(oldSettings)
    oldSettings = {}
  end if

  if not isValid(newSettings)
    return
  end if

  ' Check for new or modified libraries
  for each libraryId in newSettings
    newLibSettings = newSettings[libraryId]

    if not oldSettings.DoesExist(libraryId)
      ' New library - sync all its settings
      syncLibrarySettings(libraryId, newLibSettings)
    else
      ' Existing library - check for changed settings
      oldLibSettings = oldSettings[libraryId]
      syncChangedLibrarySettings(libraryId, oldLibSettings, newLibSettings)
    end if
  end for

  ' Check for deleted libraries
  for each libraryId in oldSettings
    if not newSettings.DoesExist(libraryId)
      ' Library was deleted - remove from registry
      deleteLibraryFromRegistry(libraryId)
    end if
  end for
end sub

' Sync all settings for a library (new library case)
sub syncLibrarySettings(libraryId as string, settings as object)
  if not isValid(settings)
    return
  end if

  ' Get local reference to minimize rendezvous
  localUser = m.global.user
  if not isValid(localUser.id)
    m.log.warn("Cannot write to registry - user ID is invalid")
    return
  end if

  m.log.info("Syncing all settings for new library", { libraryId: libraryId, settingsCount: settings.Count() })

  ' Display settings are nested structures stored in the displaySettings field
  ' We write directly to registry using registry_write() instead of set_user_setting()
  ' because set_user_setting() tries to create node fields with dots in the name, which is invalid
  for each key in settings
    registryKey = "display." + libraryId + "." + key
    m.log.debug("Writing to registry", { key: registryKey, value: settings[key] })

    ' Write directly to registry - data is already in m.top.displaySettings (source of truth)
    registry_write(registryKey, valueToString(settings[key]), localUser.id)
  end for
end sub

' Sync only changed settings for a library
sub syncChangedLibrarySettings(libraryId as string, oldSettings as object, newSettings as object)
  if not isValid(newSettings)
    return
  end if

  if not isValid(oldSettings)
    oldSettings = {}
  end if

  ' Get local reference to minimize rendezvous
  localUser = m.global.user
  if not isValid(localUser.id)
    m.log.warn("Cannot sync display settings - user ID is invalid")
    return
  end if

  changedCount = 0

  ' Check for new or modified settings
  for each key in newSettings
    newValue = newSettings[key]

    ' Sync if: key is new OR value changed
    ' Convert to strings for comparison (registry stores strings)
    ' This handles type mismatches when displaySettings contains mixed types
    oldValueStr = valueToString(oldSettings[key])
    newValueStr = valueToString(newValue)

    if not oldSettings.DoesExist(key) or oldValueStr <> newValueStr
      registryKey = "display." + libraryId + "." + key
      m.log.debug("Writing changed setting to registry", { key: registryKey, oldValue: oldValueStr, newValue: newValueStr })

      ' Write directly to registry - data is already in m.top.displaySettings (source of truth)
      registry_write(registryKey, valueToString(newValue), localUser.id)
      changedCount++
    end if
  end for

  ' Check for deleted settings
  for each key in oldSettings
    if not newSettings.DoesExist(key)
      registryKey = "display." + libraryId + "." + key
      m.log.debug("Deleting setting from registry", { key: registryKey })

      ' Delete directly from registry - data was already removed from m.top.displaySettings
      registry_delete(registryKey, localUser.id)
      changedCount++
    end if
  end for

  if changedCount > 0
    m.log.info("Synced changed settings for library", { libraryId: libraryId, changedCount: changedCount })
  end if
end sub

' Delete all settings for a library from registry
sub deleteLibraryFromRegistry(libraryId as string)
  ' Get local reference to minimize rendezvous
  localUser = m.global.user
  if not isValid(localUser.id)
    m.log.warn("Cannot delete library from registry - user ID is invalid")
    return
  end if

  ' Known display setting keys
  possibleKeys = ["sortField", "filter", "landing", "sortAscending", "filterOptions", "view"]

  for each key in possibleKeys
    registryKey = "display." + libraryId + "." + key
    ' Delete directly from registry - data was already removed from m.top.displaySettings
    registry_delete(registryKey, localUser.id)
  end for
end sub

' Deep copy an associative array (for state tracking)
function deepCopyAA(source as object) as object
  if not isValid(source)
    return {}
  end if

  result = {}

  for each key in source
    value = source[key]

    ' If value is an AA, recursively copy it
    if type(value) = "roAssociativeArray"
      result[key] = deepCopyAA(value)
    else
      result[key] = value
    end if
  end for

  return result
end function