-- WirePlumber
--
-- Copyright © 2020-2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
--
-- Handle new linkables and trigger rescanning of the graph.
-- Rescan the graph by pushing new select-target events for
-- all linkables that need to be linked
-- Cleanup links when the linkables they are associated with are removed.
-- Also, cleanup flags attached to linkables.

lutils = require ("linking-utils")
cutils = require ("common-utils")
futils = require ("filter-utils")
log = Log.open_topic ("s-linking")
handles = {}
handles.rescan_enabled = true
handles.timeout_source = nil

function checkFilter (si, om, handle_nonstreams)
  -- always handle filters if handle_nonstreams is true, even if it is disabled
  if handle_nonstreams then
    return true
  end

  -- always return true if this is not a filter
  local node = si:get_associated_proxy ("node")
  local link_group = node:get_property ("node.link-group")
  if link_group == nil then
    return true
  end

  local direction = cutils.getTargetDirection (si.properties)

  -- always handle filters that are not smart
  if not futils.is_filter_smart (direction, link_group) then
    return true
  end

  -- dont handle smart filters that are disabled
  return not futils.is_filter_disabled (direction, link_group)
end

function checkLinkable (si, om, handle_nonstreams)
  -- For the rest of them, only handle stream session items
  if si:get_property ("item.node.type") ~= "stream" and
      not handle_nonstreams then
    return false
  end

  -- check filters
  if not checkFilter (si, om, handle_nonstreams) then
    return false
  end

  return true
end

function unhandleLinkable (si, om)
  if not checkLinkable (si, om, true) then
    return
  end

  local si_id = si.id
  log:info (si, string.format ("unhandling item %d", si_id))

  -- iterate over all the links in the graph and
  -- remove any links associated with this item
  for silink in om:iterate { type = "SiLink" } do
    local silink_props = silink.properties
    local out_id = silink_props:get_int ("out.item.id")
    local in_id = silink_props:get_int ("in.item.id")

    if out_id == si_id or in_id == si_id then
      local in_flags = lutils:get_flags (in_id)
      local out_flags = lutils:get_flags (out_id)

      if out_id == si_id and in_flags.peer_id == out_id then
        in_flags.peer_id = nil
      elseif in_id == si_id and out_flags.peer_id == in_id then
        out_flags.peer_id = nil
      end

      if silink_props:get_boolean ("is.role.policy.link") then
        lutils.clearPriorityMediaRoleLink(silink)
      end

      silink:remove ()
      log:info (silink, "... link removed")
    end
  end

  lutils:clear_flags (si_id)
end

SimpleEventHook {
  name = "linking/linkable-removed",
  interests = {
    EventInterest {
      Constraint { "event.type", "=", "session-item-removed" },
      Constraint { "event.session-item.interface", "=", "linkable" },
    },
  },
  execute = function (event)
    local si = event:get_subject ()
    local source = event:get_source ()
    local om = source:call ("get-object-manager", "session-item")

    unhandleLinkable (si, om)
  end
}:register ()

-- Handle newly added linkable immediately without waiting for full rescan
-- Only for simple cases where we know it won't affect other parts of the graph
SimpleEventHook {
  name = "linking/linkable-added-immediate",
  before = "linking/rescan-trigger",
  interests = {
    EventInterest {
      Constraint { "event.type", "=", "session-item-added" },
      Constraint { "event.session-item.interface", "=", "linkable" },
    },
  },
  execute = function (event)
    local si = event:get_subject ()
    local source = event:get_source ()
    local om = source:call ("get-object-manager", "session-item")

    if not checkLinkable (si, om, false) then
      return
    end

    -- Don't handle immediately if this is a smart filter that could affect other nodes
    local node = si:get_associated_proxy ("node")
    local link_group = node:get_property ("node.link-group")
    if link_group then
      local direction = cutils.getTargetDirection (si.properties)
      if futils.is_filter_smart (direction, link_group) then
        -- Smart filters need full rescan to handle cascading effects
        return
      end
    end

    -- Only handle if autoconnect is enabled
    local autoconnect = si:get_property ("node.autoconnect")
    if autoconnect ~= "true" then
      return
    end

    -- Check if this is a simple stream (most common case)
    -- Don't handle device nodes or special nodes that might become default targets
    if si:get_property ("item.node.type") ~= "stream" then
      return
    end

    -- Push select-target event immediately for simple stream case
    source:call ("push-event", "select-target", si, nil)
  end
}:register ()

function handleLinkables (source)
  local om = source:call ("get-object-manager", "session-item")

  for si in om:iterate { type = "SiLinkable" } do
    if not checkLinkable (si, om) then
      goto skip_linkable
    end

    -- Get properties
    local si_props = si.properties

    -- check if we need to link this node at all
    local autoconnect = si_props:get_boolean ("node.autoconnect")
    if not autoconnect then
      log:debug (si, tostring (si_props ["node.name"]) .. " does not need to be autoconnected")
      goto skip_linkable
    end

    -- push event to find target and link
    source:call ("push-event", "select-target", si, nil)

    ::skip_linkable::
  end
end

SimpleEventHook {
  name = "linking/rescan",
  interests = {
    EventInterest {
      Constraint { "event.type", "=", "rescan-for-linking" },
    },
  },
  execute = function (event)
    local source = event:get_source ()
    local om = source:call ("get-object-manager", "session-item")

    log:info ("rescanning...")

    -- always unlink all filters that are smart and disabled
    for si in om:iterate {
        type = "SiLinkable",
        Constraint { "node.link-group", "+" },
    } do
      local node = si:get_associated_proxy ("node")
      local link_group = node:get_property ("node.link-group")
      local direction = cutils.getTargetDirection (si.properties)
      if futils.is_filter_smart (direction, link_group) and
          futils.is_filter_disabled (direction, link_group) then
        unhandleLinkable (si, om)
      end
    end

    handleLinkables (source)
  end
}:register ()

SimpleEventHook {
  name = "linking/rescan-trigger",
  interests = {
    -- on device Routes changed
    EventInterest {
      Constraint { "event.type", "=", "device-params-changed" },
      Constraint { "event.subject.param-id", "c", "Route", "EnumRoute" },
    },
    -- on any "default" target changed
    EventInterest {
      Constraint { "event.type", "=", "metadata-changed" },
      Constraint { "metadata.name", "=", "default" },
      Constraint { "event.subject.key", "c", "default.audio.source",
          "default.audio.sink", "default.video.source" },
    },
    -- on any "filters" metadata changed
    EventInterest {
      Constraint { "event.type", "=", "metadata-changed" },
      Constraint { "metadata.name", "=", "filters" },
    },
  },
  execute = function (event)
    if handles.rescan_enabled then
      local source = event:get_source ()
      source:call ("schedule-rescan", "linking")
    end
  end
}:register ()

SimpleEventHook {
  name = "linking/session-item-added",
  before = "linking/rescan-trigger",
  interests = {
    EventInterest {
      Constraint { "event.type", "=", "session-item-added" },
    },
  },
  execute = function (event)
    -- clear timeout source, if any
    if handles.timeout_source ~= nil then
      handles.timeout_source:destroy ()
      handles.timeout_source = nil
    end

    -- Always enable rescan when any node is added
    handles.rescan_enabled = true
  end
}:register ()

-- Stop rescan for 2 seconds if BT item was removed. This avoids audio
-- being played on internal nodes for a few seconds while the BT device is
-- switching profiles.
SimpleEventHook {
  name = "linking/bluez-session-item-removed",
  before = "linking/rescan-trigger",
  interests = {
    EventInterest {
      Constraint { "event.type", "=", "session-item-removed" },
      Constraint { "device.api", "=", "bluez5" },
    },
  },
  execute = function (event)
    local si = event:get_subject ()
    local source = event:get_source ()

    -- clear timeout source, if any
    if handles.timeout_source ~= nil then
      handles.timeout_source:destroy ()
      handles.timeout_source = nil
    end

    -- disable rescan
    handles.rescan_enabled = false

    -- re-enable rescan after 2 seconds
    handles.timeout_source = Core.timeout_add (2000, function()
      handles.timeout_source = nil
      handles.rescan_enabled = true
      source:call ("schedule-rescan", "linking")
    end)
  end
}:register ()

function handleMoveSetting (enable)
  if (not handles.move_hook) and (enable == true) then
    handles.move_hook = SimpleEventHook {
      name = "linking/rescan-trigger-on-target-metadata-changed",
      interests = {
        EventInterest {
          Constraint { "event.type", "=", "metadata-changed" },
          Constraint { "metadata.name", "=", "default" },
          Constraint { "event.subject.key", "c", "target.object", "target.node" },
        },
      },
      execute = function (event)
        local source = event:get_source ()
        source:call ("schedule-rescan", "linking")
      end
    }
    handles.move_hook:register()
  elseif (handles.move_hook) and (enable == false) then
    handles.move_hook:remove ()
    handles.move_hook = nil
  end
end

Settings.subscribe ("linking.allow-moving-streams", function ()
  handleMoveSetting (Settings.get_boolean ("linking.allow-moving-streams"))
end)
handleMoveSetting (Settings.get_boolean ("linking.allow-moving-streams"))
