source_utils_config.bs

' needed for setUserSetting() and unsetUserSetting()
import "pkg:/source/utils/session.bs"

' Read config tree from json config file and return
function GetConfigTree()
  return ParseJSON(ReadAsciiFile("pkg:/settings/settings.json"))
end function

' Generic registry accessors
function registryRead(key, section = invalid)
  if not isValid(section) then return invalid
  reg = CreateObject("roRegistrySection", section)
  if reg.exists(key) then return reg.read(key)
  return invalid
end function

sub registryWrite(key, value, section = invalid)
  if not isValid(section) then return
  reg = CreateObject("roRegistrySection", section)
  reg.write(key, value)
  reg.flush()
end sub

' Convert dynamic value to string for registry storage
function valueToString(value as dynamic) as string
  if not isValid(value) then return ""

  valueType = type(value)

  if valueType = "roString" or valueType = "String"
    return value
  else if valueType = "roBoolean" or valueType = "Boolean"
    if value then return "true" else return "false"
  else if valueType = "roInt" or valueType = "roInteger" or valueType = "Integer"
    return value.toStr()
  else if valueType = "roFloat" or valueType = "Float" or valueType = "roDouble" or valueType = "Double"
    return value.toStr()
  else
    ' Fallback - try toStr() method
    return value.toStr()
  end if
end function

sub registryDelete(key, section = invalid)
  if not isValid(section) then return
  reg = CreateObject("roRegistrySection", section)
  reg.delete(key)
  reg.flush()
end sub

' Return all data found inside a registry section
function RegistryReadAll(section as string) as dynamic
  if section = "" then return invalid

  registry = CreateObject("roRegistrySection", section)
  regKeyList = registry.GetKeyList()
  registryData = {}
  for each item in regKeyList
    if registry.Exists(item)
      registryData.AddReplace(item, registry.Read(item))
    end if
  end for

  return registryData
end function

' Return an array of all the registry section keys
function getRegistrySections() as object
  registry = CreateObject("roRegistry")
  return registry.GetSectionList()
end function

' Helper to get the global registry section name
' Returns "test-global" during tests, "JellyRock" in production
function getGlobalRegistrySection() as string
  ' Detect test mode by checking if m.global.user.id starts with "test-"
  ' This is more reliable than compile-time flags since test user IDs always use this pattern
  globalUser = m.global.user
  if isValid(globalUser) and isValid(globalUser.id)
    userId = globalUser.id
    if type(userId) = "roString" or type(userId) = "String"
      if userId.StartsWith("test-")
        return "test-global"
      end if
    end if
  end if
  return "JellyRock"
end function

' "JellyRock" registry accessors for the default global settings
function getSetting(key, defaultValue = invalid)
  value = registryRead(key, getGlobalRegistrySection())
  if not isValid(value) then return defaultValue
  return value
end function

sub setSetting(key, value)
  ' Registry only accepts strings - convert value to string
  registryWrite(key, valueToString(value), getGlobalRegistrySection())
end sub

sub unsetSetting(key)
  registryDelete(key, getGlobalRegistrySection())
end sub

' User registry accessors for the currently active user
function getUserSetting(key as string) as dynamic
  globalUser = m.global.user
  if key = "" or not isValid(globalUser.id) then return invalid
  value = registryRead(key, globalUser.id)
  return value
end function

sub setUserSetting(key as string, value as dynamic)
  ' Get local reference to minimize rendezvous
  localUser = m.global.user
  if not isValid(localUser.id) then return

  ' Use node structure as source of truth - check where this field belongs
  if localUser.hasField(key)
    ' Field exists on user node - set it there
    localUser.setField(key, value)
  else if localUser.settings.hasField(key)
    ' Field exists on settings node - use settings system
    user.settings.Save(key, value)
  else
    ' Unknown field - registry-only (backward compatibility, or keys like "serverId")
    ' Don't set on any node, just persist to registry
  end if

  ' Always persist to registry for all keys
  registryWrite(key, valueToString(value), localUser.id)
end sub

sub unsetUserSetting(key as string)
  globalUser = m.global.user
  if not isValid(globalUser.id) then return
  ' Delete from registry only (for sensitive data like token, username)
  ' These fields are excluded from the observer, so deleting from registry
  ' won't trigger the observer to re-save them
  registryDelete(key, globalUser.id)
end sub

' Helper to determine if a setting is global (applies to all users)
function isGlobalSetting(key as string) as boolean
  return key.StartsWith("global")
end function

' Filter registry keys to find those that should be deleted during a settings reset
' Preserves session/identity keys, deletes everything else
' @param {object} allKeys - associative array of all registry key-value pairs
' @param {object} preserveKeys - array of key names to preserve
' @return {object} - array of key names that should be deleted
function getSettingKeysToDelete(allKeys as object, preserveKeys as object) as object
  keysToDelete = []
  for each key in allKeys
    shouldPreserve = false
    for each preserveKey in preserveKeys
      ' Case-insensitive compare: roAssociativeArray iteration returns lowercase keys
      if LCase(key) = LCase(preserveKey)
        shouldPreserve = true
        exit for
      end if
    end for
    if not shouldPreserve
      keysToDelete.push(key)
    end if
  end for
  return keysToDelete
end function

' Recursivly search the config tree for entry with settingname equal to key
function findConfigTreeKey(key as string, tree)
  for each item in tree
    if isValid(item.settingName) and item.settingName = key then return item

    if isValid(item.children) and item.children.Count() > 0
      result = findConfigTreeKey(key, item.children)
      if isValid(result) then return result
    end if
  end for

  return invalid
end function

' The range a settings.json entry declares, or invalid when it declares none.
'
' Every rejection below exists because `Int()` on the alternative is a Type Mismatch that
' takes down the Settings screen the moment the user OPENS that setting. Both shapes were
' measured on a Stick 4K rather than reasoned about, and neither is exotic:
'
'   - ONE bound declared. Relaxing this to "honor whichever bound is present" makes the
'     other one `Int(invalid)`. (The weaker reason — the dialog text names both ends, so one
'     bound alone would read "the supported range of 1 to invalid" — is true, and is not the
'     one that matters.)
'   - A bound written as a STRING. `Int("1")` faults the same way, and it is the likelier
'     typo of the two: `default` in the SAME entry is conventionally a string ("16"), so
'     `"min": "1"` reads as consistent while being the one spelling that crashes.
'
' A malformed entry therefore degrades to "declares no range" — the setting still saves, it
' is just unbounded — rather than taking the screen down. It does not pass silently either:
' tests/scripts/unit/settings-schema.test.js fails the PR on every shape rejected here, so
' this guard is the runtime floor and the schema test is the enforcement.
'
' Lives here rather than in the settings screen so it can be unit-tested without the
' component, alongside findConfigTreeKey which reads the same tree.
function settingRangeBounds(entry as object) as object
  if not isValid(entry) then return invalid
  if not isIntSafe(entry.min) or not isIntSafe(entry.max) then return invalid
  return { min: Int(entry.min), max: Int(entry.max) }
end function

' Is this value safe to hand to `Int()`?
'
' `Int()` faults with a Type Mismatch on anything that is not a number — measured on a Stick
' 4K — and BrighterScript cannot catch it, because every caller here holds the value as
' `dynamic`. So the check is by TYPE rather than by coercion: accepting "1" would legitimize
' two spellings of one field and let the JSON drift between them, and the schema test forces
' the right one at PR time. An absent value reports its type as invalid and lands here too,
' which is why callers need no separate isValid() check.
'
' Named for the question rather than for either caller: it guards `settingRangeBounds`
' (authored JSON) and `resolveHomeRowLimit` (a node field), which share nothing except that
' both would fault on the same input.
function isIntSafe(value as dynamic) as boolean
  valueType = type(value)
  return valueType = "roInt" or valueType = "roInteger" or valueType = "Integer" or valueType = "roFloat" or valueType = "Float" or valueType = "roDouble" or valueType = "Double" or valueType = "roLongInteger" or valueType = "LongInteger"
end function

' Mirrors the `uiHomeRowLimit` default in settings/settings.json, and is reached only when
' that setting is missing or unusable. Pinned to the JSON by a unit test rather than by this
' comment, because a comment cannot fail.
const HOME_ROW_LIMIT_DEFAULT = 32

' How many items a Home browse FEED asks the server for.
'
' ONE number for all three feeds — Recently Added, On Now, Active Recordings — because they
' had no reason to differ. All three sent 16, and they were tied to each other only by comment
' ("16 to be consistent with Latest In", "parity with onNow"). Sharing the setting makes that
' parity a fact instead of a claim three files have to keep agreeing on by hand.
'
' The line this setting does NOT cross is feed vs worklist:
'
'   FEED     - a sample of a larger set (these three). A limit chooses how much of the sample
'              to show, so it is a real user preference and nothing is hidden by raising it.
'   WORKLIST - a set the user is trying to get THROUGH. Continue Watching and Next Up send no
'              limit at all, deliberately: any cap hides something already started, and no
'              number is the right one to hide at. Next Up sent an arbitrary 69 until this
'              change removed it; Continue Watching has always sent none.
'
' The Favorites TAB (`itemsToLoad = "favorites"`, loaded by FavoritesRows) is not a Home row
' at all, so it is out of scope on identity rather than on this rule.
'
' The number is the `uiHomeRowLimit` user setting; this is the guard for the setting not
' being there. NOT a second copy of the range enforced when the value is SAVED —
' components/settings/settings.bs reads `min`/`max` straight off the settings.json entry, so
' the range has one source of truth and it is not this function.
'
' Zero is the case worth naming. It is what an integer node field reads before SaveDefaults()
' has populated it, and it is a legal value to send: Jellyfin would answer a `Limit=0` with an
' empty list, so every affected row would silently come back EMPTY rather than fail. A
' fallback that only checked `isValid` would let that through.
'
' Pure — the caller passes the field rather than this reading `m.global` — so it is unit
' testable without a global, and so a Task thread pays one rendezvous at its own call site
' instead of one hidden in here.
'
' @param settingValue - the raw uiHomeRowLimit field, possibly unset
' @return the item limit to send, never below 1
function resolveHomeRowLimit(settingValue as dynamic) as integer
  ' `isIntSafe` rather than `isValid`, for the reason its own docblock gives: `Int()` faults
  ' on a non-number, and this parameter is `dynamic`. Every caller today reads a field
  ' declared `integer` in JellyfinUserSettings.xml, so no live path can reach that fault —
  ' this is the same guard `settingRangeBounds` carries, held to the same standard, so the
  ' two cannot drift into disagreeing about what is safe to hand to `Int()`. A Task thread is
  ' where all three call sites run, and a fault there is the failure class this project
  ' exists to remove.
  if not isIntSafe(settingValue) then return HOME_ROW_LIMIT_DEFAULT
  limit = Int(settingValue)
  if limit < 1 then return HOME_ROW_LIMIT_DEFAULT
  return limit
end function

' Bring a typed integer inside a declared range.
'
' Returns the value unchanged when it already fits, so a caller can compare against its input
' to decide whether the user needs telling. Kept separate from `settingRangeBounds` for that
' reason — the SCREEN needs to know that a change happened, not just what to store.
function clampToSettingRange(value as integer, bounds as object) as integer
  if not isValid(bounds) then return value
  if value < bounds.min then return bounds.min
  if value > bounds.max then return bounds.max
  return value
end function

' Returns an array of saved users from the registry
' that belong to the active server
function getSavedUsers() as object
  registrySections = getRegistrySections()

  savedUsers = []
  for each section in registrySections
    if LCase(section) <> "jellyrock"
      savedUsers.push(section)
    end if
  end for

  savedServerUsers = []
  for each userId in savedUsers
    userArray = {
      id: userId
    }
    token = registryRead("authToken", userId)

    username = registryRead("username", userId)
    if isValid(username)
      userArray["username"] = username
    end if

    serverId = registryRead("serverId", userId)
    if isValid(serverId)
      userArray["serverId"] = serverId
    end if

    primaryImageTag = registryRead("primaryImageTag", userId)
    if isValid(primaryImageTag)
      print "Found Saved Primary Image Tag: ", primaryImageTag, " for user: ", userId
      userArray["primaryImageTag"] = primaryImageTag
    end if

    if isValid(username) and isValid(token) and serverIdsMatch(serverId, m.global.server.id)
      savedServerUsers.push(userArray)
    end if
  end for

  return savedServerUsers
end function

' Saved-server list persistence. Pure registry operations on the saved_servers key —
' config.bs's domain, alongside getSavedUsers — called from both SetServerScreen (render
' thread, delete) and the loginRouter coordinator (main thread, save).
sub SaveServerList()
  ' Save this server to the list of previously-used servers shown in the server picker.
  ' baseUrl: canonical URL (lowercase) — used for deduplication against SSDP-discovered servers.
  ' originalUrl: user-entered URL from registry — shown in the picker and pre-filled on re-selection,
  '              so inferServerUrl() can re-discover the correct protocol on each connection.
  ' id: Jellyfin server ID — primary deduplication key, robust to URL changes (e.g. HTTP→HTTPS).
  globalServer = m.global.server
  serverUrl = globalServer.serverUrl
  serverId = globalServer.id
  serverName = globalServer.name
  originalUrl = getSetting("server") ' set correctly by server.UpdateURL() before this is called

  if isValid(serverUrl)
    serverUrl = LCase(serverUrl) ' canonical URL always lowercase for comparison
  end if
  if not isValidAndNotEmpty(originalUrl)
    originalUrl = serverUrl ' fallback: use canonical if original is somehow missing
  end if

  savedServers = { serverList: [] }
  saved = getSetting("saved_servers")
  if isValid(saved)
    parsed = ParseJson(saved)
    if isValid(parsed) and isValid(parsed.serverList)
      savedServers = parsed
    end if
  end if

  ' Check for an existing entry (ID-based first; URL fallback for old entries without id)
  for i = 0 to savedServers.serverList.Count() - 1
    item = savedServers.serverList[i]
    isMatch = false
    if isValidAndNotEmpty(serverId) and isValidAndNotEmpty(item.id)
      isMatch = serverIdsMatch(item.id, serverId)
    else if LCase(item.baseUrl) = serverUrl
      isMatch = true
    end if

    if isMatch
      ' Update in-place: refresh mutable server identity fields (name, id, baseUrl, originalUrl).
      ' iconUrl/iconWidth/iconHeight are static app branding defaults and are not changed.
      savedServers.serverList[i].name = serverName
      savedServers.serverList[i].id = serverId
      savedServers.serverList[i].baseUrl = serverUrl ' keep canonical URL current (e.g. HTTP→HTTPS)
      savedServers.serverList[i].originalUrl = originalUrl
      setSetting("saved_servers", FormatJson(savedServers))
      return
    end if
  end for

  ' No existing entry found — append a new one
  savedServers.serverList.Push({
    name: serverName,
    id: serverId,
    baseUrl: serverUrl,
    originalUrl: originalUrl,
    iconUrl: "pkg:/images/branding/logo-icon120.jpg",
    iconWidth: 120,
    iconHeight: 120
  })
  setSetting("saved_servers", FormatJson(savedServers))
end sub

sub DeleteFromServerList(idOrUrl as string)
  ' idOrUrl should be the server's id when available (passed from itemToDelete.id).
  ' Falls back to a canonical baseUrl for legacy entries that predate the id field.
  ' ID match is tried first so deletion is correct even when the saved entry's baseUrl
  ' differs from the picker item's baseUrl (e.g. a saved HTTPS entry matched via SSDP
  ' on HTTP — the picker item carries the SSDP baseUrl, not the saved one).
  saved = getSetting("saved_servers")
  if not isValid(saved) then return

  savedServers = ParseJson(saved)
  newServers = { serverList: [] }
  normalizedInput = LCase(idOrUrl) ' for URL fallback comparison (baseUrls are always lowercase)
  for each item in savedServers.serverList
    keepEntry = true
    if serverIdsMatch(item.id, idOrUrl)
      keepEntry = false ' ID match — remove this entry
    else if item.baseUrl = normalizedInput
      keepEntry = false ' URL fallback — remove this entry
    end if
    if keepEntry
      newServers.serverList.Push(item)
    end if
  end for
  setSetting("saved_servers", FormatJson(newServers))
end sub

' True when two Jellyfin server ids refer to the same server. Jellyfin GUIDs are canonical
' lower-case hex, but fold both sides so a mis-cased id (e.g. from an external deep-link/cast
' sender) still matches. The single source of truth for "same server?" across the saved-server
' list and the saved-user filter — keeps every id comparison consistent.
function serverIdsMatch(a as dynamic, b as dynamic) as boolean
  return isValidAndNotEmpty(a) and isValidAndNotEmpty(b) and LCase(a) = LCase(b)
end function

' Pure: match the saved_servers JSON against a server GUID, case-insensitively (via
' serverIdsMatch). Split out from the registry read (findSavedServerByGuid in replayRoute.bs)
' so the match logic is unit-testable without touching the registry (getSetting reads the real
' "JellyRock" section, which tests must not write). Lives here alongside the saved_servers shape.
function findServerInList(savedJson as dynamic, guid as string) as object
  if not isValidAndNotEmpty(guid) or not isValid(savedJson) then return invalid
  parsed = ParseJson(savedJson)
  if not isValid(parsed) or not isValid(parsed.serverList) then return invalid
  for each entry in parsed.serverList
    if serverIdsMatch(entry.id, guid) then return entry
  end for
  return invalid
end function