lib/foundry/context/graph_builder.ex

defmodule Foundry.Context.GraphBuilder do
  @moduledoc """
  Assembles the complete project graph by collecting all nodes and deriving edges
  between them based on structural and behavioral relationships.

  Edge derivation rules:
  - Reactor `:create`/`:update` steps → resource: `writes` edge
  - Reactor `:read`/`:read_one` steps → resource: `reads` edge
  - Oban worker with `@performs` → Reactor: `async` edge
  - Resource `belongs_to` relationship: `references` edge
  - Resource `has_many`/`has_one` relationship: `referenced_by` edge
  """

  alias Foundry.Context.{ModuleDiscovery, NodeBuilder, PendingMigrations, EdgeEntry, NodeEntry, RouterIntrospector}
  alias Foundry.SparkMeta, as: FoundrySparkMeta

  @spec build(String.t(), list()) :: {list(NodeEntry.t()), list(EdgeEntry.t())}
  def build(project_root, manifest) do
    root_name = Keyword.get(manifest, :project_name, "")
    app_name = Macro.underscore(root_name) |> String.to_atom()
    {:ok, pending_set} = PendingMigrations.check(project_root)

    # Build router routes map keyed by module string for page route enrichment
    router_routes =
      case RouterIntrospector.find_router(app_name, project_root) do
        nil -> %{}
        router ->
          RouterIntrospector.liveview_routes(router)
          |> Map.new(fn r -> {format_module(r.module), r} end)
      end

    nodes =
      ModuleDiscovery.all_project_modules(project_root, root_name)
      |> Task.async_stream(
        fn mod ->
          info = FoundrySparkMeta.walk(mod)
          pending = PendingMigrations.pending?(mod, pending_set)
          node = NodeBuilder.build(info, manifest, pending)
          enrich_page_route(node, router_routes)
        end,
        max_concurrency: System.schedulers_online(),
        timeout: 30_000
      )
      |> Enum.map(fn {:ok, node} -> node end)
      |> Enum.sort_by(& &1.id)

    edges =
      nodes
      |> derive_edges()
      |> Enum.sort_by(&{&1.from, &1.to})

    # Add external infrastructure nodes and their edges (Phase C)
    {external_nodes, external_edges} = derive_external_nodes_and_edges(nodes)
    all_nodes = Enum.sort_by(nodes ++ external_nodes, & &1.id)
    all_edges =
      edges
      |> Kernel.++(external_edges)
      |> filter_resolvable_edges(all_nodes)
      |> Enum.sort_by(&{&1.from, &1.to})

    {all_nodes, all_edges}
  end

  defp enrich_page_route(%NodeEntry{type: "page"} = node, router_routes) do
    case Map.get(router_routes, node.module) do
      nil -> node
      route -> %{node | page_route: route.path, page_dynamic: route.dynamic}
    end
  end

  defp enrich_page_route(node, _router_routes), do: node

  # ---------------------------------------------------------------------------
  # Edge derivation
  # ---------------------------------------------------------------------------

  defp derive_edges(nodes) do
    edge_list = []

    # Build a map for quick lookup: module_fqn -> node
    node_map = Map.new(nodes, fn node -> {node.module, node} end)

    # Derive edges from all sources
    edge_list = edge_list ++ derive_reactor_edges(nodes, node_map)
    edge_list = edge_list ++ derive_job_edges(nodes, node_map)
    edge_list = edge_list ++ derive_resource_edges(nodes, node_map)
    edge_list = edge_list ++ derive_auth_edges(nodes, node_map)
    edge_list = edge_list ++ derive_rule_edges(nodes, node_map)
    edge_list = edge_list ++ derive_policy_edges(nodes, node_map)
    edge_list = edge_list ++ derive_adapter_edges(nodes, node_map)
    edge_list = edge_list ++ derive_trigger_edges(nodes, node_map)
    edge_list = edge_list ++ derive_page_edges(nodes, node_map)

    Enum.uniq(edge_list)
  end

  # Reactor steps: data-driven derivation from normalized read_targets/write_targets.
  defp derive_reactor_edges(nodes, _node_map) do
    nodes
    |> Enum.filter(&(&1.type in ["reactor", "transfer"]))
    |> Enum.flat_map(fn reactor ->
      reactor.steps
      |> Enum.flat_map(fn step ->
        read_targets = step_targets(step, :read_targets, :read)
        write_targets = step_targets(step, :write_targets, :write)

        step_name = Map.get(step, :name) || Map.get(step, "name")
        step_index = Map.get(step, :step_index) || Map.get(step, "step_index")
        target_action = infer_step_action_name(step)

        Enum.map(
          read_targets,
          &new_step_edge(reactor.module, &1, :reads, step_name, step_index, target_action)
        ) ++
          Enum.map(
            write_targets,
            &new_step_edge(reactor.module, &1, :writes, step_name, step_index, target_action)
          )
      end)
    end)
  end

  # Oban jobs: linking to Reactor via @performs attribute or domain heuristic
  defp derive_job_edges(nodes, node_map) do
    nodes
    |> Enum.filter(&(&1.type == "job"))
    |> Enum.flat_map(fn job ->
      cond do
        job.performs ->
          [EdgeEntry.new(job.module, job.performs, :async)]

        true ->
          reactor = find_reactor_in_domain(node_map, job.domain)
          if reactor, do: [EdgeEntry.new(job.module, reactor.module, :async)], else: []
      end
    end)
  end

  # Helper: find a reactor in the same domain as the job
  defp find_reactor_in_domain(node_map, domain) do
    node_map
    |> Enum.find(fn {_module, node} ->
      node.type == "reactor" and node.domain == domain
    end)
    |> then(&if &1, do: elem(&1, 1), else: nil)
  end

  # Resource relationships: derived directly from live resource structure.
  defp derive_resource_edges(nodes, node_map) do
    known_modules = Map.keys(node_map) |> MapSet.new()

    nodes
    |> Enum.filter(&(&1.type == "resource"))
    |> Enum.flat_map(fn resource ->
      resource.module
      |> to_existing_module()
      |> relationship_edges_for(known_modules)
      |> Enum.map(&EdgeEntry.new(resource.module, &1.to, &1.relation))
    end)
  end

  # Authentication edges: authentication subjects connect to token resources.
  defp derive_auth_edges(nodes, node_map) do
    known_modules = Map.keys(node_map) |> MapSet.new()

    nodes
    |> Enum.filter(&(&1.authentication_subject == true))
    |> Enum.flat_map(fn user_node ->
      explicit_tokens =
        user_node.module
        |> to_existing_module()
        |> auth_token_resources()

      (explicit_tokens ++ implicit_auth_targets(user_node, explicit_tokens))
      |> Enum.uniq()
      |> Enum.filter(&MapSet.member?(known_modules, &1))
      |> Enum.map(&EdgeEntry.new(user_node.module, &1, :authenticates))
    end)
  end

  # Adapter edges: connect integration adapter modules to external systems
  defp derive_adapter_edges(nodes, _node_map) do
    nodes
    |> Enum.filter(&(&1.type == "adapter"))
    |> Enum.flat_map(fn adapter ->
      adapter_name = extract_adapter_name(adapter)
      [EdgeEntry.new(adapter.module, "external:#{adapter_name}", :calls_adapter)]
    end)
  end

  # Rule usage edges are derived from normalized step.rules_applied facts.
  # This keeps graph links source-truthful (rule evaluate/check calls), not prose-driven.
  defp derive_rule_edges(nodes, node_map) do
    nodes
    |> Enum.filter(&(&1.type in ["reactor", "transfer"]))
    |> Enum.flat_map(fn reactor ->
      reactor.steps
      |> Enum.flat_map(fn step ->
        rule_refs = Map.get(step, :rules_applied) || Map.get(step, "rules_applied") || []
        step_name = Map.get(step, :name) || Map.get(step, "name")
        step_index = Map.get(step, :step_index) || Map.get(step, "step_index")

        rule_refs
        |> Enum.filter(&(Map.has_key?(node_map, &1) and node_map[&1].type == "rule"))
        |> Enum.map(fn rule_module ->
          %EdgeEntry{
            from: rule_module,
            to: reactor.module,
            relation: :guards,
            step_name: step_name && to_string(step_name),
            step_index: step_index
          }
        end)
      end)
    end)
  end

  defp derive_policy_edges(nodes, node_map) do
    nodes
    |> Enum.filter(&(&1.type == "resource"))
    |> Enum.flat_map(fn resource ->
      resource.module
      |> to_existing_module()
      |> case do
        nil ->
          []

        module ->
          if ash_resource_module?(module) do
            module
            |> Ash.Policy.Info.policies()
            |> Enum.flat_map(fn policy ->
              check_modules = policy_check_modules(policy, node_map)
              scoped_actions = policy_action_names(policy, resource.actions)

              case scoped_actions do
                [] ->
                  Enum.map(check_modules, &EdgeEntry.new(&1, resource.module, :guards))

                action_names ->
                  for check_module <- check_modules,
                      action_name <- action_names do
                    %EdgeEntry{
                      from: check_module,
                      to: resource.module,
                      relation: :guards,
                      action_name: action_name
                    }
                  end
              end
            end)
          else
            []
          end
      end
    end)
  end

  defp derive_trigger_edges(nodes, node_map) do
    nodes
    |> Enum.filter(&(&1.type == "trigger"))
    |> Enum.flat_map(fn trigger ->
      trigger.side_effects
      |> Enum.flat_map(fn side_effect ->
        case side_effect.type do
          :oban_emit ->
            target = resolve_emitted_target(side_effect.name, node_map)

            if Map.has_key?(node_map, target) do
              [EdgeEntry.new(trigger.module, target, :enqueues)]
            else
              []
            end

          _ ->
            []
        end
      end)
    end)
  end

  defp step_targets(step, key, expected_kind) do
    case Map.get(step, key) || Map.get(step, to_string(key)) do
      targets when is_list(targets) and targets != [] -> targets
      target when is_binary(target) -> [target]
      _ -> fallback_targets(step, expected_kind)
    end
  end

  defp fallback_targets(step, expected_kind) do
    target_resource = Map.get(step, :target_resource) || Map.get(step, "target_resource")
    step_kind = Map.get(step, :step_kind) || Map.get(step, "step_kind")
    read_targets = Map.get(step, :read_targets) || Map.get(step, "read_targets") || []
    source_snippet = Map.get(step, :source_snippet) || Map.get(step, "source_snippet")

    cond do
      expected_kind == :read and step_kind in [:read, "read"] and is_binary(target_resource) ->
        [target_resource]

      expected_kind == :write and step_kind in [:write, "write"] and is_binary(target_resource) ->
        [target_resource]

      expected_kind == :write ->
        infer_write_targets_from_source(source_snippet, read_targets)

      true ->
        []
    end
  end

  defp infer_write_targets_from_source(nil, _read_targets), do: []

  defp infer_write_targets_from_source(source_snippet, read_targets) when is_binary(source_snippet) do
    explicit_targets =
      Regex.scan(
        ~r/([A-Z][A-Za-z0-9_.]*)\s*\|>\s*Ash\.Changeset\.for_(?:create|update|destroy)\(/,
        source_snippet
      )
      |> Enum.map(fn [_, target] -> target end)

    inferred_variable_targets =
      Regex.scan(
        ~r/(\w+)\s*\|>\s*Ash\.Changeset\.for_(?:update|destroy)\(/,
        source_snippet
      )
      |> Enum.flat_map(fn [_, variable] -> infer_variable_write_targets(variable, read_targets) end)

    Enum.uniq(explicit_targets ++ inferred_variable_targets)
  end

  defp infer_write_targets_from_source(_source_snippet, _read_targets), do: []

  defp infer_variable_write_targets(variable, read_targets) do
    inferred =
      Enum.filter(read_targets, fn resource ->
        resource
        |> String.split(".")
        |> List.last()
        |> Macro.underscore()
        |> Kernel.==(variable)
      end)

    cond do
      inferred != [] ->
        inferred

      length(Enum.uniq(read_targets)) == 1 ->
        read_targets

      true ->
        []
    end
  end

  defp to_existing_module(module_name) when is_binary(module_name) do
    String.to_existing_atom("Elixir." <> module_name)
  rescue
    _ -> nil
  end

  defp format_module(nil), do: nil

  defp format_module(module) when is_atom(module) do
    module |> Atom.to_string() |> String.replace_prefix("Elixir.", "")
  end

  defp format_module(module) when is_binary(module), do: module
  defp format_module(_), do: nil

  defp relationship_edges_for(nil, _known_modules), do: []

  defp relationship_edges_for(module, known_modules) do
    module
    |> Ash.Resource.Info.relationships()
    |> Enum.filter(fn relationship ->
      Map.get(relationship, :public?, true) ||
        not String.starts_with?(to_string(Map.get(relationship, :name, "")), "paper_trail")
    end)
    |> Enum.flat_map(fn relationship ->
      related = format_module(Map.get(relationship, :destination))

      if is_binary(related) and MapSet.member?(known_modules, related) do
        case relationship_type(relationship) do
          :belongs_to ->
            [%{to: related, relation: :references}]

          :has_many ->
            [%{to: related, relation: :referenced_by}]

          :has_one ->
            [%{to: related, relation: :referenced_by}]

          :many_to_many ->
            [%{to: related, relation: :references}, %{to: related, relation: :referenced_by}]

          _ ->
            []
        end
      else
        []
      end
    end)
  rescue
    _ -> []
  end

  defp relationship_type(%{__struct__: struct_module}) do
    case struct_module do
      Ash.Resource.Relationships.BelongsTo -> :belongs_to
      Ash.Resource.Relationships.HasMany -> :has_many
      Ash.Resource.Relationships.HasOne -> :has_one
      Ash.Resource.Relationships.ManyToMany -> :many_to_many
      _ -> :unknown
    end
  end

  defp relationship_type(_relationship), do: :unknown

  defp auth_token_resources(nil), do: []

  defp auth_token_resources(module) do
    if SparkMeta.spark_module?(module) do
      global_token_resource =
        module
        |> SparkMeta.get_opt([:authentication, :tokens], :token_resource, nil)
        |> format_module()

      module
      |> SparkMeta.entities([:authentication, :strategies])
      |> Enum.map(fn strategy ->
        token_resource =
          strategy
          |> Map.get(:token_resource)
          |> format_module()

        token_resource || global_token_resource
      end)
      |> Enum.reject(&is_nil/1)
    else
      []
    end
  rescue
    _ -> []
  end

  defp implicit_auth_targets(user_node, []),
    do: ["#{user_app_prefix(user_node)}.#{user_node.domain}.Token"]

  defp implicit_auth_targets(_user_node, _explicit_tokens), do: []

  defp user_app_prefix(user_node) do
    user_node.module
    |> String.split(".")
    |> List.first()
  end

  defp normalize_emitted_target(target) when is_binary(target) do
    case String.starts_with?(target, "Elixir.") do
      true -> String.replace_prefix(target, "Elixir.", "")
      false -> target
    end
  end

  defp resolve_emitted_target(target, node_map) when is_binary(target) do
    normalized = normalize_emitted_target(target)

    cond do
      Map.has_key?(node_map, normalized) ->
        normalized

      true ->
        case Enum.filter(Map.keys(node_map), &String.ends_with?(&1, "." <> normalized)) do
          [resolved] -> resolved
          _ -> normalized
        end
    end
  end

  defp new_step_edge(from, to, relation, step_name, step_index, action_name) do
    %EdgeEntry{
      from: from,
      to: to,
      relation: relation,
      step_name: step_name && to_string(step_name),
      step_index: step_index,
      action_name: action_name
    }
  end

  defp infer_step_action_name(step) do
    explicit_action =
      step
      |> Map.get(:target_action)
      |> Kernel.||(Map.get(step, "target_action"))
      |> normalize_action_name()

    explicit_action ||
      step
      |> Map.get(:source_snippet)
      |> Kernel.||(Map.get(step, "source_snippet"))
      |> infer_action_name_from_source()
  end

  defp infer_action_name_from_source(nil), do: nil

  defp infer_action_name_from_source(source_snippet) when is_binary(source_snippet) do
    with [_, action_name] <-
           Regex.run(
             ~r/Ash\.(?:create|update|destroy|get|read|read_one)\(\s*[^,\n]+,\s*:(\w+)/m,
             source_snippet
           ) do
      action_name
    else
      _ ->
        with [_, action_name] <-
               Regex.run(
                 ~r/Ash\.(?:create|update|destroy|get|read|read_one)\([^)]*action:\s*:(\w+)/m,
                 source_snippet
               ) do
          action_name
        else
          _ ->
            with [_, action_name] <-
                   Regex.run(
                     ~r/Ash\.Changeset\.for_(?:create|update|destroy)\(\s*:(\w+)/m,
                     source_snippet
                   ) do
              action_name
            else
              _ -> nil
            end
        end
    end
  end

  defp infer_action_name_from_source(_source_snippet), do: nil

  defp policy_check_modules(policy, node_map) do
    policy
    |> Map.get(:policies, [])
    |> Enum.map(&Map.get(&1, :check_module))
    |> Enum.reject(&is_nil/1)
    |> Enum.map(&format_module/1)
    |> Enum.reject(&is_nil/1)
    |> Enum.filter(&Map.has_key?(node_map, &1))
    |> Enum.uniq()
  end

  defp policy_action_names(policy, resource_actions) do
    policy
    |> Map.get(:condition, [])
    |> Enum.flat_map(&policy_condition_action_names(&1, resource_actions))
    |> Enum.uniq()
  end

  defp policy_condition_action_names({Ash.Policy.Check.Action, opts}, _resource_actions) do
    opts
    |> Keyword.get(:action, [])
    |> List.wrap()
    |> Enum.map(&normalize_action_name/1)
    |> Enum.reject(&is_nil/1)
  end

  defp policy_condition_action_names({Ash.Policy.Check.ActionType, opts}, resource_actions) do
    action_types =
      opts
      |> Keyword.get(:type, [])
      |> List.wrap()
      |> Enum.map(&normalize_action_type/1)
      |> Enum.reject(&is_nil/1)

    resource_actions
    |> List.wrap()
    |> Enum.filter(fn action ->
      action_type =
        action
        |> Map.get(:type)
        |> normalize_action_type()

      action_type in action_types
    end)
    |> Enum.map(fn action ->
      action
      |> Map.get(:name)
      |> normalize_action_name()
    end)
    |> Enum.reject(&is_nil/1)
  end

  defp policy_condition_action_names(_condition, _resource_actions), do: []

  defp normalize_action_name(nil), do: nil

  defp normalize_action_name(action_name) when is_atom(action_name),
    do: Atom.to_string(action_name)

  defp normalize_action_name(action_name) when is_binary(action_name), do: action_name
  defp normalize_action_name(_action_name), do: nil

  defp normalize_action_type(nil), do: nil

  defp normalize_action_type(action_type) when is_atom(action_type),
    do: Atom.to_string(action_type)

  defp normalize_action_type(action_type) when is_binary(action_type), do: action_type
  defp normalize_action_type(_action_type), do: nil

  defp ash_resource_module?(module) do
    Ash.Resource.Info.resource?(module)
  rescue
    _ -> false
  end

  # ---------------------------------------------------------------------------
  # External node synthesis (Phase C)
  # ---------------------------------------------------------------------------

  defp derive_external_nodes_and_edges(nodes) do
    # Collect edges to external postgres (single canonical node)
    postgres_edges =
      for n <- nodes,
          n.type == "resource",
          n.data_layer && String.contains?(to_string(n.data_layer), "AshPostgres"),
          do: EdgeEntry.new(n.module, "external:postgres", :persists_to)

    oban_edges =
      for n <- nodes,
          n.type == "job",
          n.oban_queues && length(n.oban_queues) > 0,
          do: EdgeEntry.new(n.module, "external:oban_queue", :queues_via)

    # Collect adapter edges and extract unique adapter names
    adapter_edges =
      for n <- nodes,
          n.type == "adapter",
          do: EdgeEntry.new(n.module, "external:#{extract_adapter_name(n)}", :calls_adapter)

    adapter_names =
      adapter_edges
      |> Enum.map(& &1.to)
      |> Enum.uniq()

    external_edges = postgres_edges ++ oban_edges ++ adapter_edges

    # Create external postgres node (single canonical node if there are postgres resources)
    postgres_nodes =
      if length(postgres_edges) > 0 do
        [
          %NodeEntry{
            module: "external:postgres",
            id: "external:postgres",
            type: "external",
            domain: "Infrastructure",
            description: "PostgreSQL database",
            app: nil,
            sensitive: false,
            attributes: [],
            actions: [],
            rules: [],
            compliance: [],
            adrs: [],
            runbook: nil,
            test_coverage: %{property_tests: false, scenario_tests: false, e2e_tests: false},
            data_layer: nil,
            pending_migrations: false,
            paper_trail: false,
            archival: false,
            state_machine: nil,
            api_routes: [],
            telemetry_prefix: nil,
            money_attributes: [],
            authentication_subject: false,
            oban_queues: [],
            rate_limited: false,
            feature_flags: [],
            steps: [],
            performs: nil,
            outputs: [],
            agent_steps: [],
            relationships: [],
            auth_strategies: [],
            last_modified: nil
          }
        ]
      else
        []
      end

    # Oban external node (singleton)
    oban_node =
      if length(oban_edges) > 0 do
        [
          %NodeEntry{
            module: "external:oban_queue",
            id: "external:oban_queue",
            type: "external",
            domain: "Infrastructure",
            description: "Oban background job queue",
            app: nil,
            sensitive: false,
            attributes: [],
            actions: [],
            rules: [],
            compliance: [],
            adrs: [],
            runbook: nil,
            test_coverage: %{property_tests: false, scenario_tests: false, e2e_tests: false},
            data_layer: nil,
            pending_migrations: false,
            paper_trail: false,
            archival: false,
            state_machine: nil,
            api_routes: [],
            telemetry_prefix: nil,
            money_attributes: [],
            authentication_subject: false,
            oban_queues: [],
            rate_limited: false,
            feature_flags: [],
            steps: [],
            performs: nil,
            outputs: [],
            agent_steps: [],
            relationships: [],
            auth_strategies: [],
            last_modified: nil
          }
        ]
      else
        []
      end

    # Adapter external nodes
    adapter_nodes =
      adapter_names
      |> Enum.map(fn adapter_id ->
        # Extract adapter name from "external:adapter_name"
        adapter_name = String.replace(adapter_id, "external:", "")
        human_name = humanize_adapter_name(adapter_name)

        %NodeEntry{
          module: adapter_id,
          id: adapter_id,
          type: "external",
          domain: "Infrastructure",
          description: "External API: #{human_name}",
          app: nil,
          sensitive: false,
          attributes: [],
          actions: [],
          rules: [],
          compliance: [],
          adrs: [],
          runbook: nil,
          test_coverage: %{property_tests: false, scenario_tests: false, e2e_tests: false},
          data_layer: nil,
          pending_migrations: false,
          paper_trail: false,
          archival: false,
          state_machine: nil,
          api_routes: [],
          telemetry_prefix: nil,
          money_attributes: [],
          authentication_subject: false,
          oban_queues: [],
          rate_limited: false,
          feature_flags: [],
          steps: [],
          performs: nil,
          outputs: [],
          agent_steps: [],
          relationships: [],
          auth_strategies: [],
          last_modified: nil
        }
      end)

    # Feature flag external nodes (one per unique flag name across all page nodes)
    feature_flag_names =
      nodes
      |> Enum.filter(&(&1.type in ["page", "liveview"]))
      |> Enum.flat_map(fn n ->
        flags = n.feature_flags || []
        if is_list(flags), do: flags, else: [flags]
      end)
      |> Enum.uniq()

    feature_flag_nodes =
      Enum.map(feature_flag_names, fn flag_name ->
        flag_id = "external:feature_flag:#{flag_name}"

        %NodeEntry{
          module: flag_id,
          id: flag_id,
          type: "external",
          domain: "Infrastructure",
          description: "Feature flag: #{flag_name}",
          app: nil,
          sensitive: false,
          attributes: [],
          actions: [],
          rules: [],
          compliance: [],
          adrs: [],
          runbook: nil,
          test_coverage: %{property_tests: false, scenario_tests: false, e2e_tests: false},
          data_layer: nil,
          pending_migrations: false,
          paper_trail: false,
          archival: false,
          state_machine: nil,
          api_routes: [],
          telemetry_prefix: nil,
          money_attributes: [],
          authentication_subject: false,
          oban_queues: [],
          rate_limited: false,
          feature_flags: [],
          steps: [],
          performs: nil,
          outputs: [],
          agent_steps: [],
          relationships: [],
          auth_strategies: [],
          last_modified: nil
        }
      end)

    external_nodes = postgres_nodes ++ oban_node ++ adapter_nodes ++ feature_flag_nodes

    {external_nodes, external_edges}
  end

  defp filter_resolvable_edges(edges, nodes) do
    valid_ids =
      nodes
      |> Enum.map(& &1.id)
      |> MapSet.new()

    Enum.filter(edges, fn edge ->
      MapSet.member?(valid_ids, edge.from) and MapSet.member?(valid_ids, edge.to)
    end)
  end

  # Extract adapter name from an adapter node
  defp extract_adapter_name(adapter) do
    case Regex.run(~r/@adapter_name\s+"([^"]+)"/, adapter.description || "") do
      [_, name] ->
        String.downcase(name)

      _ ->
        # Fallback: use last segment of module name in snake_case
        adapter.module
        |> String.split(".")
        |> List.last()
        |> String.downcase()
    end
  end

  # Humanize adapter name for display in external node descriptions
  # e.g. "pragmaticplayv1" -> "Pragmatic Play V1"
  defp humanize_adapter_name(adapter_name) do
    adapter_name
    |> String.split(~r/(?=[A-Z])|_/)
    |> Enum.reject(&(&1 == ""))
    |> Enum.map(&String.capitalize/1)
    |> Enum.join(" ")
  end

  # Page edges: page → action (calls_action) and page → feature flag (feature_flagged_by)
  defp derive_page_edges(nodes, node_map) do
    edge_list = []

    # calls_action edges: page → resource from calls_actions list
    edge_list =
      edge_list ++
        (nodes
         |> Enum.filter(&(&1.type in ["page", "liveview"]))
         |> Enum.flat_map(fn page ->
           (page.calls_actions || [])
           |> then(fn calls -> if is_list(calls), do: calls, else: [calls] end)
           |> Enum.map(fn call_action ->
             resource_str =
               case call_action do
                 %{"resource" => res} -> res
                 {resource_module, _action_type} -> format_module(resource_module)
                 _ -> nil
               end

             if resource_str && Map.has_key?(node_map, resource_str) do
               action_name = Map.get(call_action, "action_name")

               %EdgeEntry{
                 from: page.module,
                 to: resource_str,
                 relation: :calls_action,
                 action_name: action_name
               }
             else
               nil
             end
           end)
           |> Enum.reject(&is_nil/1)
         end))

    # feature_flagged_by edges: page → feature flag
    edge_list =
      edge_list ++
        (nodes
         |> Enum.filter(&(&1.type in ["page", "liveview"]))
         |> Enum.flat_map(fn page ->
           (page.feature_flags || [])
           |> then(fn flags -> if is_list(flags), do: flags, else: [flags] end)
           |> Enum.map(fn flag_name ->
             flag_node_id = "external:feature_flag:#{flag_name}"
             EdgeEntry.new(page.module, flag_node_id, :feature_flagged_by)
           end)
         end))

    edge_list
  end
end