source_extras_extrasRows.bs

import "pkg:/source/constants/itemAspectRatio.bs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/misc.bs"

' Decision layer for ItemDetails' extras rows — which rows an item type shows, in what order
' and slot shape, how a delivered row is ordered and filtered, when a run may be committed to
' the RowList and which rows it may skip, and the layout arrays for the rows on screen.
'
' It lives in source/ rather than in ExtrasRowList.bs because a component codebehind's
' functions are scoped to that component: a Rooibos suite cannot call them without a callFunc
' seam on the XML. Same move as source/home/latestRows.bs.
'
' Everything here is pure: no nodes are created, no fields written, no translation looked up.
' A plan row names its title by translation key so the consumer resolves it on the render
' thread, and the item argument may be a node or an AA.
namespace extrasRows

  ' The ordered rows for an item type.
  '
  ' Each row is an AA:
  '   kind      - what to fetch; the orchestrator maps it to a request ("chapters" and "cast"
  '               are built from the item itself, with no request)
  '   shape     - "PORTRAIT" | "WIDE" | "SQUARE"; finishRow may revise it (playlists)
  '   titleKey  - translation key; titleArgs holds its placeholders when it has any
  '   itemId    - the id the request is keyed on
  '   seasonId  - season rows only
  '   currentId - rows that put the viewed item first
  '   excludeId - rows that drop the viewed item
  '
  ' A row whose request cannot be formed for this item (an episode with no season, a track
  ' with no album) is left out of the plan rather than planned and resolved empty.
  '
  ' @param itemType - the item's type, e.g. "Movie"
  ' @param item - the item node or AA
  ' @return array of row AAs in display order
  function plan(itemType as string, item as object) as object
    itemId = item.id ?? ""
    rows = []

    if itemType = "Person"
      rows.push(planRow("personMovies", "PORTRAIT", translationKeys.LabelMovies, itemId))
      rows.push(planRow("personEpisodes", "WIDE", translationKeys.LabelEpisodes, itemId))
      rows.push(planRow("personSeries", "PORTRAIT", translationKeys.LabelSeries, itemId))
      return rows
    end if

    if itemType = "Series"
      rows.push(planRow("seasons", "PORTRAIT", translationKeys.LabelSeasons, itemId))
      appendCastAndLikeThis(rows, itemType, item)
    else if itemType = "Season"
      row = planRow("episodes", "WIDE", translationKeys.LabelEpisodes, item.seriesId ?? "")
      row.seasonId = itemId
      rows.push(row)
      appendCastAndLikeThis(rows, itemType, item)
    else if itemType = "Episode"
      rows.push(planRow("chapters", "WIDE", translationKeys.LabelChapters, itemId))
      if isValidAndNotEmpty(item.seasonId)
        rows.push(seasonEpisodesRow(item))
      end if
      appendCastAndLikeThis(rows, itemType, item)
    else if itemType = "MusicVideo"
      appendCastAndLikeThis(rows, itemType, item)
    else if itemType = "BoxSet"
      rows.push(planRow("boxSetItems", "PORTRAIT", translationKeys.LabelMovies, itemId))
      appendCastAndLikeThis(rows, itemType, item)
    else if itemType = "MusicArtist"
      rows.push(planRow("artistAlbums", "SQUARE", translationKeys.LabelAlbums, itemId))
      rows.push(planRow("artistAppearsOn", "SQUARE", translationKeys.LabelAppearsOn, itemId))
      rows.push(planRow("artistSongs", "SQUARE", translationKeys.LabelSongs, itemId))
      rows.push(planRow("artistSimilar", "SQUARE", translationKeys.LabelMoreLikeThis, itemId))
    else if itemType = "MusicAlbum"
      rows.push(planRow("albumSongs", "SQUARE", translationKeys.LabelSongs, itemId))
      artistId = firstAlbumArtistId(item)
      if artistId <> ""
        row = planRow("moreAlbums", "SQUARE", translationKeys.LabelMoreAlbums, artistId)
        row.excludeId = itemId
        rows.push(row)
      end if
      rows.push(likeThisRow(itemType, item))
    else if itemType = "Audio"
      if isValidAndNotEmpty(item.albumId)
        rows.push(albumTracksRow(item))
      end if
      rows.push(likeThisRow(itemType, item))
    else if itemType = "Playlist"
      ' PORTRAIT until the items are known — finishRow makes an all-audio playlist SQUARE.
      rows.push(planRow("playlistItems", "PORTRAIT", translationKeys.LabelPlaylistItems, itemId))
      rows.push(likeThisRow(itemType, item))
    else if itemType = "Photo"
      rows.push(likeThisRow(itemType, item))
    else if itemType = "PhotoAlbum"
      rows.push(planRow("photoAlbumItems", "WIDE", translationKeys.LabelPhotos, itemId))
      rows.push(likeThisRow(itemType, item))
    else if itemType = "TvChannel"
      rows.push(planRow("channelPrograms", "SQUARE", translationKeys.LabelUpNext, itemId))
      rows.push(likeThisRow(itemType, item))
    else if itemType = "Program"
      if isValidAndNotEmpty(item.channelId)
        rows.push(planRow("channelPrograms", "SQUARE", translationKeys.LabelMoreOnThisChannel, item.channelId))
      end if
      rows.push(likeThisRow(itemType, item))
    else
      ' Movie, Video, Recording — and any type not named above, as the chain always did.
      rows.push(planRow("chapters", "WIDE", translationKeys.LabelChapters, itemId))
      rows.push(planRow("additionalParts", "PORTRAIT", translationKeys.LabelAdditionalParts, itemId))
      appendCastAndLikeThis(rows, itemType, item)
      rows.push(planRow("specialFeatures", "WIDE", translationKeys.LabelSpecialFeatures, itemId))
    end if

    return rows
  end function

  ' Orders, filters and (for playlists) re-shapes and classifies one delivered row.
  '
  ' @param row - the plan row the items were fetched for
  ' @param items - array of item nodes or AAs, in server order
  ' @return an AA { items, shape, contentKind } — contentKind is playlistContentKind's answer for
  '         a playlist row, and "" for every other row
  function finishRow(row as object, items as object) as object
    shape = row.shape
    contentKind = ""
    result = items

    if isValidAndNotEmpty(row.currentId)
      result = currentFirst(items, row.currentId)
    else if isValidAndNotEmpty(row.excludeId)
      result = []
      for each item in items
        if item.id <> row.excludeId then result.push(item)
      end for
    end if

    if row.kind = "playlistItems"
      contentKind = playlistContentKind(items)
      if contentKind = "audio" then shape = "SQUARE"
    end if

    return { items: result, shape: shape, contentKind: contentKind }
  end function

  ' What a playlist holds, for ItemDetails' Watched button and "Tracks"/"Items" label.
  '
  ' "unknown" covers both an empty playlist and one that failed to load — ItemDetails treats
  ' it as the safe default (no Watched button, "Items" label).
  '
  ' @param items - array of item nodes or AAs, or invalid
  ' @return "video" | "audio" | "mixed" | "unknown"
  function playlistContentKind(items as dynamic) as string
    if not isValidAndNotEmpty(items) then return "unknown"

    isAllAudio = true
    for each item in items
      itemType = item.type
      if itemType <> "Audio"
        isAllAudio = false
        if itemType = "Movie" or itemType = "Episode" or itemType = "Video" or itemType = "MusicVideo" or itemType = "Recording"
          return "video"
        end if
      end if
    end for

    if isAllAudio then return "audio"
    return "mixed"
  end function

  ' Has every slot of a run resolved? A run is committed to the RowList only then, in one pass.
  '
  ' @param slots - array of { status: "pending" | "ok" | "failed" | "unchanged", count }
  ' @return true when no slot is still pending
  function isRunResolved(slots as object) as boolean
    for each slot in slots
      if slot.status = "pending" then return false
    end for
    return true
  end function

  ' Is a delivered row identical to the one on screen? Such a row is not written at all: RowList
  ' redraws its row counter on any write to an on-screen row, even one that changes nothing.
  '
  ' Identity is the title plus a digest of the row's SOURCE content — the server's response body,
  ' or the item data a local row is built from — taken where that content is at hand (see
  ' digestOf). Anything the server changes about the row, including a field on an item whose id
  ' did not change (its played state, an image tag), changes the digest; so an unchanged digest
  ' means the rebuilt row would be identical, and a tile can never be left showing stale data.
  '
  ' An empty digest means the content could not be identified, and is never treated as unchanged:
  ' the row is rewritten, which is correct if it costs a counter redraw.
  '
  ' @param oldTitle - the title on screen
  ' @param oldDigest - the digest the on-screen row was built from
  ' @param newTitle - the delivered title
  ' @param newDigest - the delivered row's digest
  ' @return true when writing the delivered row would change nothing
  function isRowUnchanged(oldTitle as string, oldDigest as string, newTitle as string, newDigest as string) as boolean
    if oldDigest = "" or newDigest = "" then return false
    return oldTitle = newTitle and oldDigest = newDigest
  end function

  ' A digest identifying a row's source content.
  '
  ' Taken over the exact text the row is built from (the HTTP response body, on the Task thread
  ' that already holds it) rather than over the item nodes: comparing every field of every node on
  ' the render thread measured 252-306 ms per Refresh on a Stick 4K for a three-row movie.
  '
  ' @param text - the source text; UTF-8 encoded before digesting
  ' @return a hex MD5 digest, or "" for empty text
  function digestOf(text as string) as string
    if text = "" then return ""
    bytes = CreateObject("roByteArray")
    bytes.FromAsciiString(text)
    digest = CreateObject("roEVPDigest")
    digest.Setup("md5")
    return digest.Process(bytes)
  end function

  ' What a resolved row does to the list.
  '
  ' A failed request says nothing about what the item HAS, so it never removes a row — the
  ' same rule latestRows.drainReady applies to Home. Only an answered, empty row removes one.
  '
  ' @param status - "ok" | "failed"
  ' @param itemCount - items the row resolved with
  ' @param hasExistingRow - whether the list already shows this row
  ' @return "fill" | "remove" | "keep" | "skip"
  function rowDisposition(status as string, itemCount as integer, hasExistingRow as boolean) as string
    if status = "ok" and itemCount > 0 then return "fill"
    if not hasExistingRow then return "skip"
    if status = "ok" then return "remove"
    return "keep"
  end function

  ' What committing a resolved run does to each plan row. The caller applies it to the RowList;
  ' nothing here touches a node.
  '
  ' @param plan - the plan rows, in display order
  ' @param slots - one { status, count } per plan row ("ok" | "failed" | "unchanged")
  ' @param results - one { items, shape, digest, contentKind } per plan row, invalid where the run
  '                  did not load that row
  ' @param titles - the title each plan row would be committed with
  ' @param onScreen - kind -> { title, shape, digest, contentKind }, one entry per row on screen
  ' @return an AA:
  '   actions             - one per plan row:
  '                           "write"  build the row from its result, or rewrite the one on screen
  '                           "leave"  the row on screen stays exactly as it is
  '                           "remove" the row on screen goes
  '                           "none"   there is no row, before or after
  '   isStructureChanged  - true when a row appears, disappears or changes slot shape, which is
  '                         the only time the RowList layout needs writing
  '   playlistContentKind - the kind of the playlist row as committed ("unknown" when it is not on
  '                         screen), or invalid when the plan has no playlist row
  function commitActions(plan as object, slots as object, results as object, titles as object, onScreen as object) as object
    actions = []
    isStructureChanged = false
    committedPlaylistKind = invalid
    plannedKinds = {}

    for slot = 0 to plan.count() - 1
      kind = plan[slot].kind
      plannedKinds[kind] = true
      existing = onScreen[kind]
      hasExisting = isValid(existing)
      status = slots[slot].status

      disposition = "keep"
      if status <> "unchanged" then disposition = rowDisposition(status, slots[slot].count, hasExisting)

      action = "none"
      if disposition = "fill"
        result = results[slot]
        if hasExisting and existing.shape = result.shape and isRowUnchanged(existing.title, existing.digest, titles[slot], result.digest)
          action = "leave"
        else
          action = "write"
          if not hasExisting or existing.shape <> result.shape then isStructureChanged = true
        end if
      else if disposition = "keep" and hasExisting
        action = "leave"
      else if disposition = "remove"
        action = "remove"
        isStructureChanged = true
      end if
      actions.push(action)

      if kind = "playlistItems"
        committedPlaylistKind = "unknown"
        if action = "write"
          committedPlaylistKind = results[slot].contentKind
        else if action = "leave"
          committedPlaylistKind = existing.contentKind
        end if
      end if
    end for

    ' A row whose kind this plan does not have at all is dropped too.
    for each kind in onScreen
      if not plannedKinds.doesExist(kind) then isStructureChanged = true
    end for

    return { actions: actions, isStructureChanged: isStructureChanged, playlistContentKind: committedPlaylistKind }
  end function

  ' RowList layout for the rows on screen: one slot and one row height per row, in order.
  '
  ' @param shapes - array of "PORTRAIT" | "WIDE" | "SQUARE", one per row on screen
  ' @return an AA { rowItemSize, rowHeights }
  function rowSizes(shapes as object) as object
    rowItemSize = []
    rowHeights = []
    for each shape in shapes
      if shape = "WIDE"
        rowItemSize.push(rowSlotSize.WIDE)
        rowHeights.push(rowSlotSize.ROW_HEIGHT_WIDE)
      else if shape = "SQUARE"
        rowItemSize.push(rowSlotSize.SQUARE)
        rowHeights.push(rowSlotSize.ROW_HEIGHT_SQUARE)
      else
        rowItemSize.push(rowSlotSize.PORTRAIT)
        rowHeights.push(rowSlotSize.ROW_HEIGHT_PORTRAIT)
      end if
    end for
    return { rowItemSize: rowItemSize, rowHeights: rowHeights }
  end function

  ' ── Private helpers ──

  function planRow(kind as string, shape as string, titleKey as string, itemId as string) as object
    return { kind: kind, shape: shape, titleKey: titleKey, titleArgs: [], itemId: itemId }
  end function

  ' Cast, then More Like This — the tail every video-library type shares.
  sub appendCastAndLikeThis(rows as object, itemType as string, item as object)
    rows.push(planRow("cast", "PORTRAIT", translationKeys.LabelCastCrew, item.id ?? ""))
    rows.push(likeThisRow(itemType, item))
  end sub

  function likeThisRow(itemType as string, item as object) as object
    itemId = item.id ?? ""
    ' Jellyfin returns no similar items for an individual episode, so ask about its series.
    if itemType = "Episode" and isValidAndNotEmpty(item.seriesId)
      itemId = item.seriesId
    end if

    ' Landscape-only types have no portrait posters: a WIDE slot makes the image URL logic
    ' fetch Thumb/Backdrop instead of Primary. Music, playlists and Live TV use 1:1 primaries.
    shape = "PORTRAIT"
    if itemType = "MusicVideo" or itemType = "Video" or itemType = "Photo" or itemType = "PhotoAlbum"
      shape = "WIDE"
    else if itemType = "MusicAlbum" or itemType = "Audio" or itemType = "Playlist" or itemType = "Program" or itemType = "TvChannel"
      shape = "SQUARE"
    end if

    return planRow("likeThis", shape, translationKeys.LabelMoreLikeThis, itemId)
  end function

  function seasonEpisodesRow(item as object) as object
    row = planRow("seasonEpisodes", "WIDE", translationKeys.LabelMoreEpisodes, item.seriesId ?? "")
    row.seasonId = item.seasonId
    row.currentId = item.id ?? ""
    seasonNumber = item.parentIndexNumber
    if isValid(seasonNumber) and seasonNumber > 0
      row.titleKey = translationKeys.MessageMoreFromSeason1
      row.titleArgs = [stri(seasonNumber).trim()]
    end if
    return row
  end function

  function albumTracksRow(item as object) as object
    row = planRow("albumTracks", "SQUARE", translationKeys.LabelAlbumTracks, item.albumId)
    row.currentId = item.id ?? ""
    if isValidAndNotEmpty(item.albumName)
      row.titleKey = translationKeys.MessageMoreFrom1
      row.titleArgs = [item.albumName]
    end if
    return row
  end function

  function firstAlbumArtistId(item as object) as string
    artists = item.albumArtists
    if not isValidAndNotEmpty(artists) then return ""
    firstArtist = artists[0]
    if not isValid(firstArtist) or not isValidAndNotEmpty(firstArtist.Id) then return ""
    return firstArtist.Id
  end function

  ' The current item, then the items after it, then the items before it — e.g. viewing
  ' episode 3 of 5 gives [3, 4, 5, 1, 2]. Server order is kept when the item is absent.
  function currentFirst(items as object, currentId as string) as object
    current = invalid
    before = []
    after = []
    for each item in items
      if item.id = currentId
        current = item
      else if isValid(current)
        after.push(item)
      else
        before.push(item)
      end if
    end for

    ordered = []
    if isValid(current) then ordered.push(current)
    ordered.append(after)
    ordered.append(before)
    return ordered
  end function

end namespace