defmodule Towerops.TopologyTest do
  use Towerops.DataCase

  import Towerops.AccountsFixtures

  alias Towerops.Snmp.Device
  alias Towerops.Snmp.Interface
  alias Towerops.Topology
  alias Towerops.Topology.DeviceLink

  setup do
    user = user_fixture()
    {:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
    {:ok, site} = Towerops.Sites.create_site(%{name: "Test Site", organization_id: organization.id})

    {:ok, router} =
      Towerops.Devices.create_device(
        %{
          name: "Core-Router",
          ip_address: "10.0.0.1",
          snmp_enabled: true,
          snmp_version: "2c",
          snmp_community: "public",
          snmp_port: 161,
          site_id: site.id,
          organization_id: organization.id
        },
        bypass_limits: true
      )

    {:ok, switch} =
      Towerops.Devices.create_device(
        %{
          name: "Main-Switch",
          ip_address: "10.0.0.2",
          snmp_enabled: true,
          snmp_version: "2c",
          snmp_community: "public",
          snmp_port: 161,
          site_id: site.id,
          organization_id: organization.id
        },
        bypass_limits: true
      )

    # Create SNMP device and interface with a known MAC for the router
    router_snmp =
      %Device{}
      |> Device.changeset(%{device_id: router.id, sys_name: "core-router"})
      |> Repo.insert!()

    router_iface =
      %Interface{}
      |> Interface.changeset(%{
        snmp_device_id: router_snmp.id,
        if_index: 1,
        if_name: "eth0",
        if_phys_address: "aa:bb:cc:00:11:22"
      })
      |> Repo.insert!()

    %{
      organization: organization,
      site: site,
      router: router,
      switch: switch,
      router_snmp: router_snmp,
      router_iface: router_iface
    }
  end

  describe "build_device_lookup/1" do
    test "matches device by IP address", %{organization: org, switch: switch} do
      lookup = Topology.build_device_lookup(org.id)
      assert Topology.find_device_by_ip(lookup, "10.0.0.2") == switch.id
    end

    test "matches device by name (case-insensitive)", %{organization: org, router: router} do
      lookup = Topology.build_device_lookup(org.id)
      assert Topology.find_device_by_name(lookup, "core-router") == router.id
      assert Topology.find_device_by_name(lookup, "CORE-ROUTER") == router.id
    end

    test "matches device by MAC address", %{organization: org, router: router} do
      lookup = Topology.build_device_lookup(org.id)
      assert Topology.find_device_by_mac(lookup, "aa:bb:cc:00:11:22") == router.id
    end

    test "returns nil for unknown IP", %{organization: org} do
      lookup = Topology.build_device_lookup(org.id)
      assert Topology.find_device_by_ip(lookup, "192.168.99.99") == nil
    end

    test "returns nil for unknown MAC", %{organization: org} do
      lookup = Topology.build_device_lookup(org.id)
      assert Topology.find_device_by_mac(lookup, "ff:ff:ff:ff:ff:ff") == nil
    end

    test "returns nil for unknown name", %{organization: org} do
      lookup = Topology.build_device_lookup(org.id)
      assert Topology.find_device_by_name(lookup, "nonexistent") == nil
    end
  end

  describe "resolve_device/2" do
    test "resolves by MAC first", %{organization: org, router: router} do
      lookup = Topology.build_device_lookup(org.id)

      result =
        Topology.resolve_device(lookup, %{
          mac: "aa:bb:cc:00:11:22",
          ip: "10.0.0.2",
          name: "Main-Switch"
        })

      # MAC matches router, even though IP and name match switch
      assert result == router.id
    end

    test "falls back to IP when MAC is nil", %{organization: org, switch: switch} do
      lookup = Topology.build_device_lookup(org.id)
      result = Topology.resolve_device(lookup, %{mac: nil, ip: "10.0.0.2", name: nil})
      assert result == switch.id
    end

    test "falls back to name when MAC and IP are nil", %{organization: org, router: router} do
      lookup = Topology.build_device_lookup(org.id)
      result = Topology.resolve_device(lookup, %{mac: nil, ip: nil, name: "Core-Router"})
      assert result == router.id
    end

    test "returns nil when nothing matches", %{organization: org} do
      lookup = Topology.build_device_lookup(org.id)
      result = Topology.resolve_device(lookup, %{mac: nil, ip: nil, name: nil})
      assert result == nil
    end
  end

  describe "collect_lldp_evidence/1" do
    test "returns evidence from LLDP neighbors", %{router: router, router_iface: iface} do
      {:ok, _} =
        Towerops.Snmp.upsert_neighbor(%{
          device_id: router.id,
          interface_id: iface.id,
          protocol: "lldp",
          remote_chassis_id: "dd:ee:ff:00:11:22",
          remote_system_name: "remote-switch",
          remote_address: "10.0.0.5",
          remote_port_id: "ge-0/0/1",
          remote_capabilities: ["switch", "bridge"],
          last_discovered_at: DateTime.utc_now()
        })

      evidence = Topology.collect_lldp_evidence(router.id)
      assert length(evidence) == 1
      [ev] = evidence
      assert ev.evidence_type == "lldp_neighbor"
      assert ev.confidence == 0.95
      assert ev.remote_mac == "dd:ee:ff:00:11:22"
      assert ev.remote_ip == "10.0.0.5"
      assert ev.remote_name == "remote-switch"
      assert ev.source_interface_id == iface.id
    end

    test "returns empty list when no LLDP neighbors", %{router: router} do
      assert Topology.collect_lldp_evidence(router.id) == []
    end
  end

  describe "collect_cdp_evidence/1" do
    test "returns evidence from CDP neighbors", %{router: router, router_iface: iface} do
      {:ok, _} =
        Towerops.Snmp.upsert_neighbor(%{
          device_id: router.id,
          interface_id: iface.id,
          protocol: "cdp",
          remote_chassis_id: "11:22:33:44:55:66",
          remote_system_name: "cisco-switch",
          remote_address: "10.0.0.6",
          remote_port_id: "Gi0/1",
          remote_capabilities: ["router"],
          last_discovered_at: DateTime.utc_now()
        })

      evidence = Topology.collect_cdp_evidence(router.id)
      assert length(evidence) == 1
      [ev] = evidence
      assert ev.evidence_type == "cdp_neighbor"
      assert ev.confidence == 0.95
    end
  end

  describe "collect_mac_evidence/2" do
    setup %{switch: switch} do
      switch_snmp =
        %Device{}
        |> Device.changeset(%{device_id: switch.id, sys_name: "main-switch"})
        |> Repo.insert!()

      switch_iface =
        %Interface{}
        |> Interface.changeset(%{
          snmp_device_id: switch_snmp.id,
          if_index: 1,
          if_name: "ge-0/0/1",
          if_phys_address: "bb:cc:dd:00:11:22"
        })
        |> Repo.insert!()

      %{switch_snmp: switch_snmp, switch_iface: switch_iface}
    end

    test "returns evidence for MACs matching known devices", %{
      organization: org,
      switch: switch,
      router: router,
      switch_iface: switch_iface
    } do
      # Router's MAC (aa:bb:cc:00:11:22) seen on a switch port
      {:ok, _} =
        Towerops.Snmp.upsert_mac_address(%{
          device_id: switch.id,
          interface_id: switch_iface.id,
          mac_address: "aa:bb:cc:00:11:22",
          vlan_id: 1,
          last_seen_at: DateTime.utc_now()
        })

      lookup = Topology.build_device_lookup(org.id)
      evidence = Topology.collect_mac_evidence(switch.id, lookup)

      assert length(evidence) == 1
      [ev] = evidence
      assert ev.evidence_type == "mac_on_interface"
      assert ev.confidence == 0.7
      assert ev.remote_mac == "aa:bb:cc:00:11:22"
      assert ev.matched_device_id == router.id
      assert ev.source_interface_id == switch_iface.id
    end

    test "excludes device's own MACs (self-links)", %{
      organization: org,
      switch: switch,
      switch_iface: switch_iface
    } do
      # Switch's own MAC seen on its own port (self-link)
      {:ok, _} =
        Towerops.Snmp.upsert_mac_address(%{
          device_id: switch.id,
          interface_id: switch_iface.id,
          mac_address: "bb:cc:dd:00:11:22",
          vlan_id: 1,
          last_seen_at: DateTime.utc_now()
        })

      lookup = Topology.build_device_lookup(org.id)
      evidence = Topology.collect_mac_evidence(switch.id, lookup)

      assert evidence == []
    end

    test "returns evidence with nil matched_device_id for unknown MACs", %{
      organization: org,
      switch: switch,
      switch_iface: switch_iface
    } do
      {:ok, _} =
        Towerops.Snmp.upsert_mac_address(%{
          device_id: switch.id,
          interface_id: switch_iface.id,
          mac_address: "ff:ff:ff:00:00:01",
          vlan_id: 1,
          last_seen_at: DateTime.utc_now()
        })

      lookup = Topology.build_device_lookup(org.id)
      evidence = Topology.collect_mac_evidence(switch.id, lookup)

      assert length(evidence) == 1
      [ev] = evidence
      assert ev.evidence_type == "mac_on_interface"
      assert ev.matched_device_id == nil
      assert ev.remote_mac == "ff:ff:ff:00:00:01"
    end
  end

  describe "merge_confidence/2" do
    test "combines two independent confidence scores" do
      assert_in_delta Topology.merge_confidence(0.95, 0.7), 0.985, 0.001
    end

    test "combining with 0 returns the other score" do
      assert Topology.merge_confidence(0.8, 0.0) == 0.8
    end

    test "combining two high scores approaches 1.0" do
      assert_in_delta Topology.merge_confidence(0.95, 0.95), 0.9975, 0.001
    end
  end

  describe "upsert_link/1" do
    test "creates a new link from evidence", %{router: router, router_iface: iface} do
      now = DateTime.utc_now()

      {:ok, link} =
        Topology.upsert_link(%{
          source_device_id: router.id,
          target_device_id: nil,
          source_interface_id: iface.id,
          link_type: "lldp",
          confidence: 0.95,
          discovered_remote_mac: "dd:ee:ff:00:11:22",
          discovered_remote_ip: "10.0.0.5",
          discovered_remote_name: "remote-switch",
          last_confirmed_at: now,
          evidence: [
            %{
              evidence_type: "lldp_neighbor",
              evidence_data: %{"remote_chassis_id" => "dd:ee:ff:00:11:22"},
              observed_at: now
            }
          ]
        })

      assert link.source_device_id == router.id
      assert link.confidence == 0.95
      assert link.link_type == "lldp"

      link_with_evidence = Repo.preload(link, :evidence)
      assert length(link_with_evidence.evidence) == 1
    end

    test "updates existing link and merges confidence", %{router: router, router_iface: iface} do
      now = DateTime.utc_now()

      # Create initial LLDP link
      {:ok, _link1} =
        Topology.upsert_link(%{
          source_device_id: router.id,
          source_interface_id: iface.id,
          link_type: "lldp",
          confidence: 0.95,
          discovered_remote_mac: "dd:ee:ff:00:11:22",
          last_confirmed_at: now,
          evidence: [
            %{
              evidence_type: "lldp_neighbor",
              evidence_data: %{},
              observed_at: now
            }
          ]
        })

      # Upsert again with MAC evidence — should merge confidence
      {:ok, updated} =
        Topology.upsert_link(%{
          source_device_id: router.id,
          source_interface_id: iface.id,
          link_type: "mac_match",
          confidence: 0.7,
          discovered_remote_mac: "dd:ee:ff:00:11:22",
          last_confirmed_at: now,
          evidence: [
            %{
              evidence_type: "mac_on_interface",
              evidence_data: %{},
              observed_at: now
            }
          ]
        })

      # Confidence merged: 1 - (1-0.95)(1-0.7) = 0.985
      assert_in_delta updated.confidence, 0.985, 0.001
      # Evidence accumulated
      updated_with_evidence = Repo.preload(updated, :evidence)
      assert length(updated_with_evidence.evidence) == 2
    end

    test "populates target_device_id when matched", %{
      router: router,
      switch: switch,
      router_iface: iface
    } do
      now = DateTime.utc_now()

      {:ok, link} =
        Topology.upsert_link(%{
          source_device_id: router.id,
          target_device_id: switch.id,
          source_interface_id: iface.id,
          link_type: "lldp",
          confidence: 0.95,
          discovered_remote_mac: "bb:cc:dd:00:11:22",
          last_confirmed_at: now,
          evidence: []
        })

      assert link.target_device_id == switch.id
    end

    test "creates link without evidence list", %{router: router, router_iface: iface} do
      now = DateTime.utc_now()

      {:ok, link} =
        Topology.upsert_link(%{
          source_device_id: router.id,
          source_interface_id: iface.id,
          link_type: "lldp",
          confidence: 0.95,
          discovered_remote_mac: "dd:ee:ff:00:11:22",
          last_confirmed_at: now
        })

      assert link.id
    end
  end

  describe "process_device/2" do
    test "creates links from LLDP neighbors and resolves target device",
         %{router: router, switch: switch, router_iface: iface, organization: org} do
      # Create SNMP device for switch so its MAC is resolvable
      switch_snmp =
        %Device{}
        |> Device.changeset(%{device_id: switch.id, sys_name: "main-switch"})
        |> Repo.insert!()

      %Interface{}
      |> Interface.changeset(%{
        snmp_device_id: switch_snmp.id,
        if_index: 1,
        if_name: "ge-0/0/1",
        if_phys_address: "bb:cc:dd:00:11:22"
      })
      |> Repo.insert!()

      # Create LLDP neighbor on router pointing to switch's MAC
      {:ok, _} =
        Towerops.Snmp.upsert_neighbor(%{
          device_id: router.id,
          interface_id: iface.id,
          protocol: "lldp",
          remote_chassis_id: "bb:cc:dd:00:11:22",
          remote_system_name: "Main-Switch",
          remote_address: "10.0.0.2",
          remote_port_id: "ge-0/0/1",
          remote_capabilities: ["switch", "bridge"],
          last_discovered_at: DateTime.utc_now()
        })

      assert {:ok, :changed} = Topology.process_device(router, org.id)

      links = Topology.list_device_links(router.id)
      assert length(links) == 1
      [link] = links
      assert link.source_device_id == router.id
      assert link.target_device_id == switch.id
      assert link.link_type == "lldp"
      assert_in_delta link.confidence, 0.95, 0.01
    end

    test "creates discovered link when remote device is not managed",
         %{router: router, router_iface: iface, organization: org} do
      {:ok, _} =
        Towerops.Snmp.upsert_neighbor(%{
          device_id: router.id,
          interface_id: iface.id,
          protocol: "lldp",
          remote_chassis_id: "ff:ff:ff:00:00:01",
          remote_system_name: "unknown-device",
          remote_address: "10.99.99.99",
          remote_port_id: "eth0",
          remote_capabilities: [],
          last_discovered_at: DateTime.utc_now()
        })

      assert {:ok, :changed} = Topology.process_device(router, org.id)

      links = Topology.list_device_links(router.id)
      assert length(links) == 1
      [link] = links
      assert link.target_device_id == nil
      assert link.discovered_remote_mac == "ff:ff:ff:00:00:01"
      assert link.discovered_remote_name == "unknown-device"
      assert link.discovered_remote_ip == "10.99.99.99"
    end

    test "returns :unchanged when no evidence exists",
         %{router: router, organization: org} do
      assert {:ok, :unchanged} = Topology.process_device(router, org.id)
    end

    test "does not create self-links",
         %{router: router, router_iface: iface, organization: org} do
      # LLDP neighbor pointing back to the same device's MAC
      {:ok, _} =
        Towerops.Snmp.upsert_neighbor(%{
          device_id: router.id,
          interface_id: iface.id,
          protocol: "lldp",
          remote_chassis_id: "aa:bb:cc:00:11:22",
          remote_system_name: "Core-Router",
          remote_address: "10.0.0.1",
          remote_port_id: "eth1",
          remote_capabilities: ["router"],
          last_discovered_at: DateTime.utc_now()
        })

      assert {:ok, :unchanged} = Topology.process_device(router, org.id)
      assert Topology.list_device_links(router.id) == []
    end
  end

  describe "list_device_links/1" do
    test "returns links ordered by confidence desc", %{router: router, router_iface: iface} do
      now = DateTime.utc_now()

      {:ok, _} =
        Topology.upsert_link(%{
          source_device_id: router.id,
          source_interface_id: iface.id,
          link_type: "lldp",
          confidence: 0.95,
          discovered_remote_mac: "aa:00:00:00:00:01",
          last_confirmed_at: now
        })

      {:ok, _} =
        Topology.upsert_link(%{
          source_device_id: router.id,
          source_interface_id: iface.id,
          link_type: "arp_inference",
          confidence: 0.6,
          discovered_remote_mac: "aa:00:00:00:00:02",
          last_confirmed_at: now
        })

      links = Topology.list_device_links(router.id)
      assert length(links) == 2
      assert hd(links).confidence > List.last(links).confidence
    end
  end

  describe "infer_device_role/1" do
    test "infers access_point from LLDP wlan-ap capability reported by others",
         %{router: router, site: site, organization: organization} do
      # Create a second device whose LLDP neighbor entry reports our router
      # has wlan-ap capability (via our router's MAC address)
      {:ok, ap_device} =
        Towerops.Devices.create_device(
          %{
            name: "Test-AP",
            ip_address: "10.0.0.50",
            snmp_enabled: true,
            snmp_version: "2c",
            snmp_community: "public",
            snmp_port: 161,
            site_id: site.id,
            organization_id: organization.id
          },
          bypass_limits: true
        )

      ap_snmp =
        %Device{}
        |> Device.changeset(%{device_id: ap_device.id, sys_name: "test-ap"})
        |> Repo.insert!()

      ap_iface =
        %Interface{}
        |> Interface.changeset(%{snmp_device_id: ap_snmp.id, if_index: 1, if_name: "eth0"})
        |> Repo.insert!()

      # This neighbor on ap_device reports that our ROUTER has wlan-ap capability
      {:ok, _} =
        Towerops.Snmp.upsert_neighbor(%{
          device_id: ap_device.id,
          interface_id: ap_iface.id,
          protocol: "lldp",
          remote_chassis_id: "aa:bb:cc:00:11:22",
          remote_system_name: "Core-Router",
          remote_capabilities: ["wlan-ap"],
          last_discovered_at: DateTime.utc_now()
        })

      role = Topology.infer_device_role(router)
      assert role == "access_point"
    end

    test "infers ups from manufacturer", %{router: router, router_snmp: snmp} do
      snmp
      |> Device.changeset(%{manufacturer: "APC"})
      |> Repo.update!()

      role = Topology.infer_device_role(router)
      assert role == "ups"
    end

    test "infers firewall from manufacturer", %{router: router, router_snmp: snmp} do
      snmp
      |> Device.changeset(%{manufacturer: "Fortinet"})
      |> Repo.update!()

      role = Topology.infer_device_role(router)
      assert role == "firewall"
    end

    test "infers server from sys_descr", %{router: router, router_snmp: snmp} do
      snmp
      |> Device.changeset(%{sys_descr: "Linux server 5.15.0"})
      |> Repo.update!()

      role = Topology.infer_device_role(router)
      assert role == "server"
    end

    test "returns unknown when no evidence", %{switch: switch} do
      role = Topology.infer_device_role(switch)
      assert role == "unknown"
    end
  end

  describe "maybe_update_device_role/1" do
    test "updates role when source is inferred", %{router: router, router_snmp: snmp} do
      snmp
      |> Device.changeset(%{manufacturer: "APC"})
      |> Repo.update!()

      {:ok, updated} = Topology.maybe_update_device_role(router)
      assert updated.device_role == "ups"
      assert updated.device_role_source == "inferred"
    end

    test "does not update role when source is manual", %{router: router} do
      router
      |> Ecto.Changeset.change(%{device_role: "core_router", device_role_source: "manual"})
      |> Repo.update!()

      router = Repo.reload!(router)
      {:ok, unchanged} = Topology.maybe_update_device_role(router)
      assert unchanged.device_role == "core_router"
    end
  end

  describe "cleanup_stale_links/0" do
    test "marks links older than 24h as stale (confidence 0.1)", %{router: router, router_iface: iface} do
      old_time = DateTime.add(DateTime.utc_now(), -25, :hour)

      {:ok, link} =
        %DeviceLink{}
        |> DeviceLink.changeset(%{
          source_device_id: router.id,
          source_interface_id: iface.id,
          link_type: "lldp",
          confidence: 0.95,
          discovered_remote_mac: "aa:bb:cc:dd:ee:ff",
          last_confirmed_at: old_time
        })
        |> Repo.insert()

      {stale_count, _deleted_count} = Topology.cleanup_stale_links()

      updated = Repo.get!(DeviceLink, link.id)
      assert_in_delta updated.confidence, 0.1, 0.01
      assert stale_count >= 1
    end

    test "deletes links older than 72h", %{router: router, router_iface: iface} do
      very_old = DateTime.add(DateTime.utc_now(), -73, :hour)

      {:ok, link} =
        %DeviceLink{}
        |> DeviceLink.changeset(%{
          source_device_id: router.id,
          source_interface_id: iface.id,
          link_type: "lldp",
          confidence: 0.95,
          discovered_remote_mac: "aa:bb:cc:dd:ee:ff",
          last_confirmed_at: very_old
        })
        |> Repo.insert()

      {_stale_count, deleted_count} = Topology.cleanup_stale_links()

      assert deleted_count >= 1
      assert Repo.get(DeviceLink, link.id) == nil
    end

    test "does not touch recent links", %{router: router, router_iface: iface} do
      recent = DateTime.utc_now()

      {:ok, link} =
        %DeviceLink{}
        |> DeviceLink.changeset(%{
          source_device_id: router.id,
          source_interface_id: iface.id,
          link_type: "lldp",
          confidence: 0.95,
          discovered_remote_mac: "aa:bb:cc:dd:ee:ff",
          last_confirmed_at: recent
        })
        |> Repo.insert()

      {stale_count, deleted_count} = Topology.cleanup_stale_links()

      assert stale_count == 0
      assert deleted_count == 0
      assert Repo.get(DeviceLink, link.id).confidence == 0.95
    end
  end

  describe "get_topology_for_map/2" do
    test "returns managed device nodes on 'added' tab",
         %{router: router, switch: switch, organization: org} do
      result = Topology.get_topology_for_map(org.id, "added")

      assert length(result.nodes) == 2
      assert Enum.all?(result.nodes, &(!&1.discovered))
      node_ids = Enum.map(result.nodes, & &1.id)
      assert router.id in node_ids
      assert switch.id in node_ids
    end

    test "returns edges between managed devices on 'added' tab",
         %{router: router, switch: switch, router_iface: iface, organization: org} do
      # Create a link between router and switch
      now = DateTime.utc_now()

      {:ok, _} =
        Topology.upsert_link(%{
          source_device_id: router.id,
          target_device_id: switch.id,
          source_interface_id: iface.id,
          link_type: "lldp",
          confidence: 0.95,
          discovered_remote_mac: "bb:cc:dd:00:11:22",
          last_confirmed_at: now,
          evidence: []
        })

      result = Topology.get_topology_for_map(org.id, "added")
      assert length(result.edges) == 1
      [edge] = result.edges
      assert edge.source == router.id
      assert edge.target == switch.id
    end

    test "excludes discovered nodes on 'added' tab",
         %{router: router, router_iface: iface, organization: org} do
      now = DateTime.utc_now()

      {:ok, _} =
        Topology.upsert_link(%{
          source_device_id: router.id,
          target_device_id: nil,
          source_interface_id: iface.id,
          link_type: "lldp",
          confidence: 0.9,
          discovered_remote_mac: "ff:ff:ff:00:00:01",
          discovered_remote_name: "unknown-cpe",
          discovered_remote_ip: "10.99.99.1",
          last_confirmed_at: now,
          evidence: []
        })

      result = Topology.get_topology_for_map(org.id, "added")
      # Should not include discovered nodes or edges to them
      assert Enum.all?(result.nodes, &(!&1.discovered))
      assert result.edges == []
    end

    test "includes discovered nodes on 'all' tab",
         %{router: router, router_iface: iface, organization: org} do
      now = DateTime.utc_now()

      {:ok, _} =
        Topology.upsert_link(%{
          source_device_id: router.id,
          target_device_id: nil,
          source_interface_id: iface.id,
          link_type: "lldp",
          confidence: 0.9,
          discovered_remote_mac: "ff:ff:ff:00:00:01",
          discovered_remote_name: "unknown-cpe",
          discovered_remote_ip: "10.99.99.1",
          last_confirmed_at: now,
          evidence: []
        })

      result = Topology.get_topology_for_map(org.id, "all")
      discovered = Enum.filter(result.nodes, & &1.discovered)
      assert length(discovered) == 1
      [disc] = discovered
      assert disc.label == "unknown-cpe"
      assert disc.ip_address == "10.99.99.1"
    end

    test "includes stats", %{organization: org} do
      result = Topology.get_topology_for_map(org.id, "added")
      assert Map.has_key?(result, :stats)
      assert Map.has_key?(result.stats, :total_devices)
      assert Map.has_key?(result.stats, :added_devices)
      assert Map.has_key?(result.stats, :total_links)
    end
  end

  describe "collect_arp_evidence/2" do
    test "returns evidence for ARP entries matching known devices", %{
      organization: org,
      router: router,
      switch: switch,
      router_iface: router_iface
    } do
      {:ok, _} =
        Towerops.Snmp.upsert_arp_entry(%{
          device_id: router.id,
          interface_id: router_iface.id,
          ip_address: "10.0.0.2",
          mac_address: "bb:cc:dd:00:11:22",
          last_seen_at: DateTime.utc_now()
        })

      lookup = Topology.build_device_lookup(org.id)
      evidence = Topology.collect_arp_evidence(router.id, lookup)

      assert length(evidence) == 1
      [ev] = evidence
      assert ev.evidence_type == "arp_entry"
      assert ev.confidence == 0.6
      assert ev.remote_ip == "10.0.0.2"
      assert ev.remote_mac == "bb:cc:dd:00:11:22"
      assert ev.matched_device_id == switch.id
    end

    test "excludes device's own ARP entries (self-links)", %{
      organization: org,
      router: router,
      router_iface: router_iface
    } do
      # ARP entry pointing back to the router itself
      {:ok, _} =
        Towerops.Snmp.upsert_arp_entry(%{
          device_id: router.id,
          interface_id: router_iface.id,
          ip_address: "10.0.0.1",
          mac_address: "aa:bb:cc:00:11:22",
          last_seen_at: DateTime.utc_now()
        })

      lookup = Topology.build_device_lookup(org.id)
      evidence = Topology.collect_arp_evidence(router.id, lookup)

      assert evidence == []
    end

    test "returns evidence with nil matched_device_id for unknown entries", %{
      organization: org,
      router: router,
      router_iface: router_iface
    } do
      {:ok, _} =
        Towerops.Snmp.upsert_arp_entry(%{
          device_id: router.id,
          interface_id: router_iface.id,
          ip_address: "10.0.0.99",
          mac_address: "ff:ff:ff:00:00:99",
          last_seen_at: DateTime.utc_now()
        })

      lookup = Topology.build_device_lookup(org.id)
      evidence = Topology.collect_arp_evidence(router.id, lookup)

      assert length(evidence) == 1
      [ev] = evidence
      assert ev.evidence_type == "arp_entry"
      assert ev.matched_device_id == nil
    end
  end

  describe "list_connected_devices/1" do
    test "returns managed and discovered links for a device",
         %{router: router, switch: switch, router_iface: iface} do
      now = DateTime.utc_now()

      # Managed link
      {:ok, _} =
        Topology.upsert_link(%{
          source_device_id: router.id,
          target_device_id: switch.id,
          source_interface_id: iface.id,
          link_type: "lldp",
          confidence: 0.95,
          discovered_remote_mac: "bb:cc:dd:00:11:22",
          last_confirmed_at: now,
          evidence: []
        })

      # Discovered link
      {:ok, _} =
        Topology.upsert_link(%{
          source_device_id: router.id,
          source_interface_id: iface.id,
          link_type: "lldp",
          confidence: 0.8,
          discovered_remote_mac: "ff:ff:ff:00:00:01",
          discovered_remote_name: "CPE-Customer",
          discovered_remote_ip: "10.0.5.47",
          last_confirmed_at: now,
          evidence: []
        })

      connected = Topology.list_connected_devices(router.id)
      assert length(connected) == 2
    end

    test "returns links where device is the target too",
         %{router: router, switch: switch, router_iface: iface} do
      now = DateTime.utc_now()

      {:ok, _} =
        Topology.upsert_link(%{
          source_device_id: switch.id,
          target_device_id: router.id,
          source_interface_id: iface.id,
          link_type: "lldp",
          confidence: 0.95,
          discovered_remote_mac: "aa:bb:cc:dd:ee:ff",
          last_confirmed_at: now,
          evidence: []
        })

      connected = Topology.list_connected_devices(router.id)
      assert length(connected) == 1
    end

    test "returns empty list when no links exist", %{router: router} do
      assert Topology.list_connected_devices(router.id) == []
    end
  end
end
