-- WirePlumber
--
-- Copyright © 2025 Pauli Virtanen
--    @author Pauli Virtanen <pav@iki.fi>
--
-- SPDX-License-Identifier: MIT
--
-- Pause media player applications when (one of) their output
-- target(s) goes away.

cutils = require ("common-utils")
lutils = require ("linking-utils")

log = Log.open_topic ("s-linking.mpris")

mpris = Plugin.find ("mpris")

RESCAN_DELAY_MSEC = 1000

function initializeState ()
  -- Delaying rescan while pausing players
  pending_ops = 0
  need_rescan = false

  -- Links between nodes: links_in [in_node_id] = { out_node_id, ... }
  links_in = {}
  links_initialized = false

  -- Link nodes: link_nodes [link.id] = { in_node_id, out_node_id }
  link_nodes = {}

  -- Status
  script_active = false
end

-- Get nodes that are (indirectly) linked to `si` by link group
-- or links with input direction. Returns table { [node_id] = node, ... }
function getLinkedNodes (start_id)
  local node_om = cutils.get_object_manager ("node")
  local groups = {}
  local link_groups = {}

  log:trace(string.format("start %d", start_id))

  -- construct groups based on node.link-group
  for node in node_om:iterate () do
    local id = node ["bound-id"]

    if groups [id] ~= nil then
      return
    end

    local link_group = node.properties ["node.link-group"]
    local group = nil

    -- join via link groups
    if link_group ~= nil then
      group = groups [link_groups [link_group]]
      link_groups [link_group] = id
    end
    if group == nil then
      group = {}
    end

    group [id] = node
    groups [id] = group
  end

  -- follow links
  local group = {}
  local active = { [start_id] = true }

  while true do
    local a_id = next(active)
    if a_id == nil then
      break
    end

    active [a_id] = nil

    local b_ids = links_in [a_id]
    if b_ids ~= nil then
      for b_id in pairs (b_ids) do
        if group [b_id] == nil and groups [b_id] ~= nil then
          for k, v in pairs (groups [b_id]) do
            if group [k] == nil then
              active [k] = true
              group [k] = v
              log:trace(string.format("node %d", k))
            end
          end
        end
      end
    end
  end

  return group
end

-- Track link status.
--
-- We need to know what state links were in just before a sink node is
-- removed. We rely here on the following facts:
--
-- * When node is removed existing links to it are removed by server synchronously
-- * Although it appears server notifies link removal *after* the node, use Core sync
--   to delay handling link removal after node removal, to be sure
-- * event_priority(node-removed, session-item-removed) >  event_priority(link-removed)
--   then ensures we first handle item removal, then update links

function updateLink (in_id, out_id, remove)
  if links_in [in_id] == nil then
    links_in [in_id] = {}
  end
  if remove then
    links_in [in_id] [out_id] = nil
  else
    links_in [in_id] [out_id] = true
  end
end

function updateLinks (links_om)
  log:debug ("update links")
  for link in links_om:iterate () do
    local lprops = link.properties
    local in_id = tonumber (lprops ["link.input.node"])
    local out_id = tonumber (lprops ["link.output.node"])
    updateLink (in_id, out_id, false)
    link_nodes [link.id] = { in_id, out_id }
  end
end

function initializeLinks (source)
  if links_initialized then
    return
  end
  if source == nil then
    -- postpone to later
    return
  end

  local links_om = source:call ("get-object-manager", "link")
  updateLinks(links_om)
  links_initialized = true
end

link_hook = SimpleEventHook {
  name = "linking/mpris-pause@track-links",
  interests = {
    EventInterest {
      Constraint { "event.type", "c", "link-added", "link-removed" },
    },
  },
  execute = function (event)
    local link = event:get_subject ()
    local eprops = event:get_properties ()
    local source = event:get_source ()

    initializeLinks (source)

    local in_id
    local out_id
    local remove = false

    if eprops ["event.type"] == "link-added" then
      local lprops = link.properties
      in_id = tonumber (lprops ["link.input.node"])
      out_id = tonumber (lprops ["link.output.node"])
      link_nodes [link.id] = { in_id, out_id }
    elseif link_nodes [link.id] ~= nil then
      in_id = link_nodes [link.id] [1]
      out_id = link_nodes [link.id] [2]
      link_nodes [link.id] = nil
      remove = true
    end

    if in_id == nil or out_id == nil then
      return
    end

    Core.sync(function()
        updateLink (in_id, out_id, remove)
    end)
  end
}

-- Pause media applications associated with the streams linked to a sink to be removed
pause_hook = SimpleEventHook {
  name = "linking/mpris-pause",
  before = "linking/linkable-removed",
  interests = {
    EventInterest {
      Constraint { "event.type", "=", "session-item-removed" },
      Constraint { "item.node.type", "=", "device" },
      Constraint { "item.node.direction", "=", "input" },
    },
  },
  execute = function (event)
    local players = mpris:call ("get-players")
    if next(players) == nil then
      return
    end

    -- find clients to handle
    local si = event:get_subject ()
    local source = event:get_source ()
    local client_om = source:call ("get-object-manager", "client")
    local node = si:get_associated_proxy ("node")
    local node_group = getLinkedNodes (tonumber (si.properties ["node.id"]))
    local client_ids = {}

    for id, node in pairs (node_group) do
      local media_class = tostring(node.properties ["media.class"])
      if media_class:find ("^Stream/Output") then
        client_ids [node.properties ["client.id"]] = true
      end
    end

    -- find players to handle
    local pause_players = {}

    for client_id in pairs (client_ids) do
      local client = client_om:lookup {
        Constraint { "bound-id", "=", client_id, type = "gobject" }
      }
      if not client then
        goto next
      end

      log:debug (si, string.format ("node %s removed: was linked to client %s (%s pid %s)",
          tostring(si.properties ["node.id"]),
          tostring(client.properties ["application.name"]),
          tostring(client.properties ["application.id"]),
          tostring(client.properties ["application.process.id"])))

      for _, player in ipairs(players) do
        local match = false

        if client.properties ["pipewire.access.portal.app_id"] == nil and player ["flatpak-app-id"] == nil then
          -- assume non-flatpak apps serve audio from same or child process as MPRIS
          if player ["pid"] ~= nil then
            local keys = { "pipewire.sec.pid", "application.process.id" }
            for _, key in ipairs(keys) do
              local pid = tonumber (client.properties [key])
              if pid ~= nil and mpris:call ("match-pid", player ["pid"], pid) then
                log:debug (si, string.format(".. match %s %u ~ %u", key, pid, player ["pid"]))
                match = true
              end
            end
          end
        elseif client.properties ["pipewire.access.portal.app_id"] == player ["flatpak-app-id"] then
          local instance_id = client.properties ["pipewire.access.portal.instance_id"]
          if instance_id == nil then
            -- Only new Pipewire versions have instance_id
            log:debug (si, string.format(".. match pipewire.access.portal.app_id = %s", player ["flatpak-app-id"]))
            match = true
          elseif instance_id == player ["flatpak-instance-id"] then
            log:debug (si, string.format(".. match pipewire.access.portal.app_id = %s instance_id = %s",
                player ["flatpak-app-id"], player ["flatpak-instance-id"]))
            match = true
          end
        end

        if match then
          pause_players [player ["name"]] = true
        end
      end

      ::next::
    end

    -- handle players
    for bus_name in pairs(pause_players) do
      log:info (si, string.format("node %s removed: pausing linked media player %s",
          tostring(si.properties ["node.id"]),
          bus_name))

      local op = mpris:call ("pause", bus_name)
      if op ["result"] == 0 then
        log:debug ("pause pending")
        pending_ops = pending_ops + 1
        op:connect("notify::result", function (op, pspec)
            if pending_ops > 0 then
              pending_ops = pending_ops - 1
            end
            log:debug (string.format("pause completed, res = %d, %d remaining",
                op ["result"], pending_ops))
            if pending_ops == 0 and need_rescan then
              -- Some players respond to DBus before actually pausing output,
              -- so add also a small delay
              Core.timeout_add(RESCAN_DELAY_MSEC, function()
                  log:debug ("continue rescan")
                  source:call ("push-event", "rescan-for-linking", nil, nil)
              end)
            end
        end)
      end
    end
  end
}

-- Do not perform rescans while we are pausing media players
rescan_hook = SimpleEventHook {
  name = "linking/mpris-pause-disable-rescan",
  before = "linking/rescan",
  interests = {
    EventInterest {
      Constraint { "event.type", "=", "rescan-for-linking" },
    },
  },
  execute = function (event)
    if pending_ops > 0 then
      log:info ("rescan disabled, wait for pending operations")
      need_rescan = true
      event:stop_processing ()
    else
      need_rescan = false
    end
  end
}


function updateEnabled ()
  local enable = Settings.get_boolean ("linking.pause-playback")

  log:debug (string.format ("enabled: %s", tostring(enable)))

  if enable and not script_active then
    local source = Plugin.find ("standard-event-source")
    initializeState ()
    initializeLinks (source)
    link_hook:register ()
    pause_hook:register ()
    rescan_hook:register ()
    script_active = true
  elseif not enable and script_active then
    link_hook:remove ()
    pause_hook:remove ()
    rescan_hook:remove ()
    if need_rescan then
      local source = Plugin.find ("standard-event-source")
      source:call ("push-event", "rescan-for-linking", nil, nil)
    end
    initializeState ()
  end
end

Settings.subscribe ("linking.pause-playback", updateEnabled)
updateEnabled ()
