defmodule Towerops.Snmp.DiscoveryTest do
  use Towerops.DataCase, async: true

  import Mox
  import Towerops.AccountsFixtures

  alias Ecto.Adapters.SQL.Sandbox
  alias Towerops.Devices.Device
  alias Towerops.Repo
  alias Towerops.Snmp.Device
  alias Towerops.Snmp.Discovery
  alias Towerops.Snmp.Interface
  alias Towerops.Snmp.Sensor
  alias Towerops.Snmp.SnmpMock

  setup :verify_on_exit!

  setup do
    # Allow EventLogger GenServer to access the test's database connection
    # EventLogger is a supervised process that writes device events to the database
    event_logger_pid = Process.whereis(Towerops.Devices.EventLogger)

    if event_logger_pid do
      Sandbox.allow(Repo, self(), event_logger_pid)
    end

    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
      })

    %{organization: organization, site: site}
  end

  describe "discover_device/1" do
    test "returns error when SNMP is not enabled", %{organization: organization, site: site} do
      {:ok, device} =
        Towerops.Devices.create_device(%{
          name: "Router 1",
          ip_address: "192.168.1.1",
          site_id: site.id,
          organization_id: organization.id,
          snmp_enabled: false
        })

      assert {:error, :snmp_not_enabled} = Discovery.discover_device(device)
    end

    test "successfully discovers MikroTik device", %{organization: organization, site: site} do
      {:ok, device} =
        Towerops.Devices.create_device(%{
          name: "MikroTik Router",
          ip_address: "192.168.1.1",
          site_id: site.id,
          organization_id: organization.id,
          snmp_enabled: true,
          snmp_version: "2c",
          snmp_community: "public",
          snmp_port: 161
        })

      # Mock all GET calls (connection test + system info + interfaces + health + network sensors)
      stub(SnmpMock, :get, fn _, oid, _ ->
        case oid do
          # Connection test + Base system info
          "1.3.6.1.2.1.1.1.0" ->
            {:ok, {:octet_string, "RouterOS RB750"}}

          "1.3.6.1.2.1.1.2.0" ->
            {:ok, {:object_identifier, [1, 3, 6, 1, 4, 1, 14_988, 1]}}

          "1.3.6.1.2.1.1.3.0" ->
            {:ok, {:timeticks, 12_345}}

          "1.3.6.1.2.1.1.4.0" ->
            {:ok, {:octet_string, "admin@example.com"}}

          "1.3.6.1.2.1.1.5.0" ->
            {:ok, {:octet_string, "router1"}}

          "1.3.6.1.2.1.1.6.0" ->
            {:ok, {:octet_string, "Data Center"}}

          # MikroTik-specific system info
          "1.3.6.1.4.1.14988.1.1.7.3.0" ->
            {:ok, {:octet_string, "1234567890"}}

          "1.3.6.1.4.1.14988.1.1.7.4.0" ->
            {:ok, {:octet_string, "7.13.2"}}

          "1.3.6.1.4.1.14988.1.1.7.9.0" ->
            {:ok, {:octet_string, "RB750Gr3"}}

          "1.3.6.1.4.1.14988.1.1.7.8.0" ->
            {:ok, {:octet_string, "Main Router"}}

          "1.3.6.1.4.1.14988.1.1.7.5.0" ->
            {:ok, {:integer, 6}}

          # Network sensors (MikroTik-specific)
          "1.3.6.1.4.1.14988.1.1.1.3.0" ->
            {:ok, {:integer, 10}}

          "1.3.6.1.4.1.14988.1.1.13.1.1.1.0" ->
            {:ok, {:integer, 500}}

          # Catch-all for interface data and health sensors
          _ ->
            cond do
              # Health sensor OIDs (all return error for this test)
              String.starts_with?(oid, "1.3.6.1.4.1.14988.1.1.3.") and
                  not String.contains?(oid, "1.3.6.1.4.1.14988.1.1.13") ->
                {:error, :no_such_object}

              # Interface 1 data
              String.ends_with?(oid, ".1") ->
                parts = String.split(oid, ".")
                second_to_last = Enum.at(parts, -2)

                cond do
                  second_to_last == "2" -> {:ok, {:octet_string, "ether1"}}
                  second_to_last == "3" -> {:ok, {:integer, 6}}
                  second_to_last == "5" -> {:ok, {:gauge32, 1_000_000_000}}
                  second_to_last == "6" -> {:ok, {:octet_string, <<0x00, 0x11, 0x22, 0x33, 0x44, 0x55>>}}
                  second_to_last == "7" -> {:ok, {:integer, 1}}
                  second_to_last == "8" -> {:ok, {:integer, 1}}
                  second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "ether1"}}
                  second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "WAN"}}
                  true -> {:error, :no_such_object}
                end

              # Interface 2 data
              String.ends_with?(oid, ".2") ->
                parts = String.split(oid, ".")
                second_to_last = Enum.at(parts, -2)

                cond do
                  second_to_last == "2" -> {:ok, {:octet_string, "ether2"}}
                  second_to_last == "3" -> {:ok, {:integer, 6}}
                  second_to_last == "5" -> {:ok, {:gauge32, 1_000_000_000}}
                  second_to_last == "6" -> {:ok, {:octet_string, <<0x00, 0x11, 0x22, 0x33, 0x44, 0x56>>}}
                  second_to_last == "7" -> {:ok, {:integer, 1}}
                  second_to_last == "8" -> {:ok, {:integer, 1}}
                  second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "ether2"}}
                  second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "LAN"}}
                  true -> {:error, :no_such_object}
                end

              true ->
                {:error, :no_such_object}
            end
        end
      end)

      # Mock all walk calls
      stub(SnmpMock, :walk, fn _, oid, _ ->
        cond do
          # Interface discovery - return 2 interfaces
          oid == "1.3.6.1.2.1.2.2.1.1" ->
            {:ok,
             [
               %{oid: "1.3.6.1.2.1.2.2.1.1.1", value: {:integer, 1}},
               %{oid: "1.3.6.1.2.1.2.2.1.1.2", value: {:integer, 2}}
             ]}

          # ENTITY-SENSOR-MIB
          String.starts_with?(oid, "1.3.6.1.2.1.99") ->
            {:ok, []}

          # LLDP neighbor discovery
          String.starts_with?(oid, "1.0.8802") ->
            {:ok, []}

          # CDP neighbor discovery
          String.starts_with?(oid, "1.3.6.1.4.1.9.9.23") ->
            {:ok, []}

          # MikroTik health sensor table
          String.starts_with?(oid, "1.3.6.1.4.1.14988.1.1.3.100") ->
            {:ok, []}

          # Other walks (debug data collection, sensors, etc.)
          true ->
            {:ok, []}
        end
      end)

      assert {:ok, device} = Discovery.discover_device(device)

      # YAML profile "routeros" returns "Mikrotik RouterOS" as vendor
      # Model comes from sysDescr_regex capture (hardware group extracts model from sysDescr)
      assert device.manufacturer == "Mikrotik RouterOS"
      assert device.model == "RB750"
      assert device.sys_name == "router1"

      # Verify interfaces were created
      interfaces = Repo.all(from i in Interface, where: i.snmp_device_id == ^device.id)
      assert length(interfaces) == 2

      interface1 = Enum.find(interfaces, &(&1.if_index == 1))
      assert interface1.if_descr == "ether1"
      assert interface1.if_name == "ether1"
      assert interface1.if_alias == "WAN"
    end

    test "successfully discovers Cisco device", %{organization: organization, site: site} do
      {:ok, device} =
        Towerops.Devices.create_device(%{
          name: "Cisco Switch",
          ip_address: "192.168.1.2",
          site_id: site.id,
          organization_id: organization.id,
          snmp_enabled: true,
          snmp_version: "2c",
          snmp_community: "public",
          snmp_port: 161
        })

      # Mock all GET calls (connection test + system info + debug data)
      stub(SnmpMock, :get, fn _, oid, _ ->
        case oid do
          "1.3.6.1.2.1.1.3.0" ->
            {:ok, {:timeticks, 54_321}}

          "1.3.6.1.2.1.1.1.0" ->
            {:ok, {:octet_string, "Cisco IOS Software, C2960 Software"}}

          "1.3.6.1.2.1.1.2.0" ->
            {:ok, {:object_identifier, [1, 3, 6, 1, 4, 1, 9, 1, 1208]}}

          "1.3.6.1.2.1.1.4.0" ->
            {:ok, {:octet_string, "netadmin@cisco.com"}}

          "1.3.6.1.2.1.1.5.0" ->
            {:ok, {:octet_string, "switch1"}}

          "1.3.6.1.2.1.1.6.0" ->
            {:ok, {:octet_string, "Building A"}}

          _ ->
            {:error, :no_such_object}
        end
      end)

      # Mock all walk calls (debug data collection, interfaces, sensors, neighbors)
      stub(SnmpMock, :walk, fn _, _, _ ->
        {:ok, []}
      end)

      assert {:ok, device} = Discovery.discover_device(device)

      # YAML profile uses "Cisco IOS" as vendor name
      assert device.manufacturer == "Cisco IOS"
      # Model comes from sysDescr when using YAML profile
      assert device.model == "Cisco IOS Software, C2960 Software"
      assert device.sys_name == "switch1"
    end

    test "handles connection failure", %{organization: organization, site: site} do
      {:ok, device} =
        Towerops.Devices.create_device(%{
          name: "Unreachable Device",
          ip_address: "192.168.1.99",
          site_id: site.id,
          organization_id: organization.id,
          snmp_enabled: true,
          snmp_version: "2c",
          snmp_community: "public",
          snmp_port: 161
        })

      # Mock failed categorize_device_speed - device unresponsive
      expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.1.0", _ ->
        {:error, :timeout}
      end)

      assert {:error, :device_unresponsive} = Discovery.discover_device(device)
    end

    test "handles partial discovery failure gracefully", %{organization: organization, site: site} do
      {:ok, device} =
        Towerops.Devices.create_device(%{
          name: "Partial Device",
          ip_address: "192.168.1.3",
          site_id: site.id,
          organization_id: organization.id,
          snmp_enabled: true,
          snmp_version: "2c",
          snmp_community: "public",
          snmp_port: 161
        })

      # Mock all GET calls (connection + system info + debug data)
      # Connection: 1, System info: 6, Debug data: 6 = 13 total
      stub(SnmpMock, :get, fn _, oid, _ ->
        case oid do
          "1.3.6.1.2.1.1.3.0" ->
            {:ok, {:timeticks, 999}}

          "1.3.6.1.2.1.1.1.0" ->
            {:ok, {:octet_string, "Generic Device"}}

          _ ->
            {:ok, {:octet_string, ""}}
        end
      end)

      # Mock all walk calls in execution order:
      # 1. Debug data collection happens first (inside build_device_info)
      # 2. Then interface discovery
      # 3. Then sensor discovery
      # 4. Then neighbor discovery (LLDP + CDP)
      stub(SnmpMock, :walk, fn _, oid, _ ->
        cond do
          # Entity sensor queries (sensor discovery + debug entity_sensor_table)
          String.starts_with?(oid, "1.3.6.1.2.1.99") ->
            {:error, :timeout}

          # Interface ifIndex
          oid == "1.3.6.1.2.1.2.2.1.1" ->
            {:error, :timeout}

          # LLDP - return empty
          String.starts_with?(oid, "1.0.8802") ->
            {:ok, []}

          # CDP - return empty
          String.starts_with?(oid, "1.3.6.1.4.1.9.9.23") ->
            {:ok, []}

          # Debug data collection (if_table, ifx_table)
          String.starts_with?(oid, "1.3.6.1.2.1") ->
            {:ok, []}

          true ->
            {:error, :no_such_object}
        end
      end)

      # Should succeed even with failed interface/sensor discovery
      assert {:ok, device} = Discovery.discover_device(device)

      assert device.manufacturer == "Unknown"

      # Verify no interfaces or sensors were created
      interfaces = Repo.all(from i in Interface, where: i.snmp_device_id == ^device.id)
      sensors = Repo.all(from s in Sensor, where: s.snmp_device_id == ^device.id)

      assert interfaces == []
      assert sensors == []
    end

    test "updates existing device on re-discovery", %{organization: organization, site: site} do
      {:ok, device} =
        Towerops.Devices.create_device(%{
          name: "Existing Device",
          ip_address: "192.168.1.4",
          site_id: site.id,
          organization_id: organization.id,
          snmp_enabled: true,
          snmp_version: "2c",
          snmp_community: "public",
          snmp_port: 161
        })

      # Create initial device
      {:ok, initial_device} =
        %Device{}
        |> Device.changeset(%{
          device_id: device.id,
          manufacturer: "Old Manufacturer",
          model: "Old Model",
          sys_name: "old-name"
        })
        |> Repo.insert()

      # Mock all SNMP get calls (categorize_device_speed + connection test + system info)
      stub(SnmpMock, :get, fn _, oid, _ ->
        case oid do
          "1.3.6.1.2.1.1.1.0" ->
            {:ok, {:octet_string, "Linux server 5.4.0"}}

          "1.3.6.1.2.1.1.2.0" ->
            {:ok, {:object_identifier, [1, 3, 6, 1, 4, 1, 8072, 3, 2, 10]}}

          "1.3.6.1.2.1.1.3.0" ->
            {:ok, {:timeticks, 12_345}}

          "1.3.6.1.2.1.1.4.0" ->
            {:ok, {:octet_string, "admin@example.com"}}

          "1.3.6.1.2.1.1.5.0" ->
            {:ok, {:octet_string, "updated-name"}}

          "1.3.6.1.2.1.1.6.0" ->
            {:ok, {:octet_string, "Server Room"}}

          _ ->
            {:error, :no_such_object}
        end
      end)

      # NetSnmp profile makes many walk calls for sensors
      stub(SnmpMock, :walk, fn _, _, _ ->
        {:ok, []}
      end)

      assert {:ok, updated_device} = Discovery.discover_device(device)

      # Should be same device, updated
      assert updated_device.id == initial_device.id
      # YAML profile "linux" has vendor "Linux"
      assert updated_device.manufacturer == "Linux"
      # Model comes from sysDescr
      assert updated_device.model == "Linux server 5.4.0"
      assert updated_device.sys_name == "updated-name"
    end

    test "preserves sensor readings when rediscovering device with same sensor_index", %{
      organization: organization,
      site: site
    } do
      alias Towerops.Snmp.SensorReading

      {:ok, device} =
        Towerops.Devices.create_device(%{
          name: "Device With Sensors",
          ip_address: "192.168.1.50",
          site_id: site.id,
          organization_id: organization.id,
          snmp_enabled: true,
          snmp_version: "2c",
          snmp_community: "public",
          snmp_port: 161
        })

      # Create initial SNMP device with a sensor
      {:ok, snmp_device} =
        %Device{}
        |> Device.changeset(%{
          device_id: device.id,
          manufacturer: "Cisco",
          model: "C2960",
          sys_name: "test-switch"
        })
        |> Repo.insert()

      {:ok, sensor} =
        %Sensor{}
        |> Sensor.changeset(%{
          snmp_device_id: snmp_device.id,
          sensor_type: "temperature",
          sensor_index: "1001",
          sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.4.1001",
          sensor_descr: "CPU Temperature",
          sensor_unit: "celsius",
          sensor_divisor: 1,
          last_value: 45.0
        })
        |> Repo.insert()

      # Create historical sensor readings
      readings_to_create = [
        %{
          sensor_id: sensor.id,
          value: 42.0,
          status: "ok",
          checked_at: DateTime.add(DateTime.utc_now(), -3600, :second)
        },
        %{
          sensor_id: sensor.id,
          value: 44.0,
          status: "ok",
          checked_at: DateTime.add(DateTime.utc_now(), -1800, :second)
        },
        %{
          sensor_id: sensor.id,
          value: 45.0,
          status: "ok",
          checked_at: DateTime.utc_now()
        }
      ]

      for reading_attrs <- readings_to_create do
        %SensorReading{}
        |> SensorReading.changeset(reading_attrs)
        |> Repo.insert!()
      end

      # Verify readings exist before rediscovery
      initial_reading_count =
        Repo.aggregate(from(r in SensorReading, where: r.sensor_id == ^sensor.id), :count)

      assert initial_reading_count == 3

      # Mock SNMP responses for rediscovery
      # Use generic sysDescr/sysObjectID to fall back to Base profile (YAML "ios" has no sensors)
      stub(SnmpMock, :get, fn _, oid, _ ->
        case oid do
          "1.3.6.1.2.1.1.3.0" -> {:ok, {:timeticks, 54_321}}
          "1.3.6.1.2.1.1.1.0" -> {:ok, {:octet_string, "Generic Switch Device"}}
          "1.3.6.1.2.1.1.2.0" -> {:ok, {:object_identifier, [1, 3, 6, 1, 4, 1, 99_999]}}
          "1.3.6.1.2.1.1.4.0" -> {:ok, {:octet_string, "admin@example.com"}}
          "1.3.6.1.2.1.1.5.0" -> {:ok, {:octet_string, "test-switch"}}
          "1.3.6.1.2.1.1.6.0" -> {:ok, {:octet_string, "Data Center"}}
          # ENTITY-SENSOR-MIB sensor details for index 1001 (Base profile uses these)
          "1.3.6.1.2.1.99.1.1.1.1.1001" -> {:ok, {:integer, 8}}
          "1.3.6.1.2.1.99.1.1.1.2.1001" -> {:ok, {:integer, 9}}
          "1.3.6.1.2.1.99.1.1.1.3.1001" -> {:ok, {:integer, 0}}
          "1.3.6.1.2.1.99.1.1.1.4.1001" -> {:ok, {:integer, 47}}
          "1.3.6.1.2.1.99.1.1.1.5.1001" -> {:ok, {:integer, 1}}
          # ENTITY-MIB descriptions
          "1.3.6.1.2.1.47.1.1.1.1.2.1001" -> {:ok, {:octet_string, "CPU Temperature"}}
          "1.3.6.1.2.1.47.1.1.1.1.7.1001" -> {:ok, {:octet_string, "TempSensor1"}}
          _ -> {:error, :no_such_object}
        end
      end)

      stub(SnmpMock, :walk, fn _, oid, _ ->
        # ENTITY-SENSOR-MIB - sensor type table (entPhySensorType)
        # Base profile walks this to find sensors
        if oid == "1.3.6.1.2.1.99.1.1.1.1" do
          {:ok, [%{oid: "1.3.6.1.2.1.99.1.1.1.1.1001", value: {:integer, 8}}]}
        else
          {:ok, []}
        end
      end)

      # Run rediscovery
      assert {:ok, rediscovered_device} = Discovery.discover_device(device)

      # Verify the SNMP device was updated (same ID)
      assert rediscovered_device.id == snmp_device.id

      # Find the sensor - it should still exist (same ID preserved)
      sensors = Repo.all(from s in Sensor, where: s.snmp_device_id == ^snmp_device.id)
      assert length(sensors) == 1
      [updated_sensor] = sensors

      # The key assertion: sensor ID should be the same, preserving readings
      assert updated_sensor.id == sensor.id

      # Verify all historical readings are still there
      final_reading_count =
        Repo.aggregate(from(r in SensorReading, where: r.sensor_id == ^sensor.id), :count)

      assert final_reading_count == 3

      # Verify readings still have the correct values
      readings = Repo.all(from r in SensorReading, where: r.sensor_id == ^sensor.id, order_by: r.checked_at)
      assert length(readings) == 3
      assert Enum.map(readings, & &1.value) == [42.0, 44.0, 45.0]
    end

    test "preserves interface stats when rediscovering device with same if_index", %{
      organization: organization,
      site: site
    } do
      alias Towerops.Snmp.InterfaceStat

      {:ok, device} =
        Towerops.Devices.create_device(%{
          name: "Device With Interfaces",
          ip_address: "192.168.1.51",
          site_id: site.id,
          organization_id: organization.id,
          snmp_enabled: true,
          snmp_version: "2c",
          snmp_community: "public",
          snmp_port: 161
        })

      # Create initial SNMP device with an interface
      {:ok, snmp_device} =
        %Device{}
        |> Device.changeset(%{
          device_id: device.id,
          manufacturer: "Cisco",
          model: "C2960",
          sys_name: "test-switch"
        })
        |> Repo.insert()

      {:ok, interface} =
        %Interface{}
        |> Interface.changeset(%{
          snmp_device_id: snmp_device.id,
          if_index: 1,
          if_descr: "GigabitEthernet0/1",
          if_name: "Gi0/1",
          if_type: 6,
          if_speed: 1_000_000_000
        })
        |> Repo.insert()

      # Create historical interface stats
      stats_to_create = [
        %{
          interface_id: interface.id,
          if_in_octets: 1_000_000,
          if_out_octets: 500_000,
          if_in_errors: 0,
          if_out_errors: 0,
          checked_at: DateTime.add(DateTime.utc_now(), -3600, :second)
        },
        %{
          interface_id: interface.id,
          if_in_octets: 2_000_000,
          if_out_octets: 1_000_000,
          if_in_errors: 0,
          if_out_errors: 0,
          checked_at: DateTime.add(DateTime.utc_now(), -1800, :second)
        },
        %{
          interface_id: interface.id,
          if_in_octets: 3_000_000,
          if_out_octets: 1_500_000,
          if_in_errors: 1,
          if_out_errors: 0,
          checked_at: DateTime.utc_now()
        }
      ]

      for stat_attrs <- stats_to_create do
        %InterfaceStat{}
        |> InterfaceStat.changeset(stat_attrs)
        |> Repo.insert!()
      end

      # Verify stats exist before rediscovery
      initial_stat_count =
        Repo.aggregate(from(s in InterfaceStat, where: s.interface_id == ^interface.id), :count)

      assert initial_stat_count == 3

      # Mock SNMP responses for rediscovery
      stub(SnmpMock, :get, fn _, oid, _ ->
        case oid do
          "1.3.6.1.2.1.1.3.0" -> {:ok, {:timeticks, 54_321}}
          "1.3.6.1.2.1.1.1.0" -> {:ok, {:octet_string, "Cisco IOS Software, C2960"}}
          "1.3.6.1.2.1.1.2.0" -> {:ok, {:object_identifier, [1, 3, 6, 1, 4, 1, 9, 1, 1208]}}
          "1.3.6.1.2.1.1.4.0" -> {:ok, {:octet_string, "admin@example.com"}}
          "1.3.6.1.2.1.1.5.0" -> {:ok, {:octet_string, "test-switch"}}
          "1.3.6.1.2.1.1.6.0" -> {:ok, {:octet_string, "Data Center"}}
          # Interface details for if_index 1
          "1.3.6.1.2.1.2.2.1.2.1" -> {:ok, {:octet_string, "GigabitEthernet0/1"}}
          "1.3.6.1.2.1.2.2.1.3.1" -> {:ok, {:integer, 6}}
          "1.3.6.1.2.1.2.2.1.5.1" -> {:ok, {:gauge32, 1_000_000_000}}
          "1.3.6.1.2.1.2.2.1.6.1" -> {:ok, {:octet_string, <<0x00, 0x11, 0x22, 0x33, 0x44, 0x55>>}}
          "1.3.6.1.2.1.2.2.1.7.1" -> {:ok, {:integer, 1}}
          "1.3.6.1.2.1.2.2.1.8.1" -> {:ok, {:integer, 1}}
          "1.3.6.1.2.1.31.1.1.1.1.1" -> {:ok, {:octet_string, "Gi0/1"}}
          "1.3.6.1.2.1.31.1.1.1.18.1" -> {:ok, {:octet_string, "Uplink"}}
          _ -> {:error, :no_such_object}
        end
      end)

      stub(SnmpMock, :walk, fn _, oid, _ ->
        # Interface discovery - return same interface with if_index 1
        if oid == "1.3.6.1.2.1.2.2.1.1" do
          {:ok, [%{oid: "1.3.6.1.2.1.2.2.1.1.1", value: {:integer, 1}}]}
        else
          {:ok, []}
        end
      end)

      # Run rediscovery
      assert {:ok, rediscovered_device} = Discovery.discover_device(device)

      # Verify the SNMP device was updated (same ID)
      assert rediscovered_device.id == snmp_device.id

      # Find the interface - it should still exist (same ID preserved)
      interfaces = Repo.all(from i in Interface, where: i.snmp_device_id == ^snmp_device.id)
      assert length(interfaces) == 1
      [updated_interface] = interfaces

      # The key assertion: interface ID should be the same, preserving stats
      assert updated_interface.id == interface.id

      # Verify all historical stats are still there
      final_stat_count =
        Repo.aggregate(from(s in InterfaceStat, where: s.interface_id == ^interface.id), :count)

      assert final_stat_count == 3

      # Verify stats still have the correct values
      stats = Repo.all(from s in InterfaceStat, where: s.interface_id == ^interface.id, order_by: s.checked_at)
      assert length(stats) == 3
      assert Enum.map(stats, & &1.if_in_octets) == [1_000_000, 2_000_000, 3_000_000]
    end

    test "deletes sensors that no longer exist during rediscovery", %{organization: organization, site: site} do
      {:ok, device} =
        Towerops.Devices.create_device(%{
          name: "Device With Removed Sensor",
          ip_address: "192.168.1.52",
          site_id: site.id,
          organization_id: organization.id,
          snmp_enabled: true,
          snmp_version: "2c",
          snmp_community: "public",
          snmp_port: 161
        })

      # Create initial SNMP device with two sensors
      {:ok, snmp_device} =
        %Device{}
        |> Device.changeset(%{
          device_id: device.id,
          manufacturer: "Cisco",
          model: "C2960",
          sys_name: "test-switch"
        })
        |> Repo.insert()

      {:ok, sensor1} =
        %Sensor{}
        |> Sensor.changeset(%{
          snmp_device_id: snmp_device.id,
          sensor_type: "celsius",
          sensor_index: "1001",
          sensor_oid: "1.3.6.1.2.1.99.1.1.1.4.1001",
          sensor_descr: "CPU Temperature",
          sensor_unit: "°C",
          sensor_divisor: 1
        })
        |> Repo.insert()

      {:ok, _sensor2} =
        %Sensor{}
        |> Sensor.changeset(%{
          snmp_device_id: snmp_device.id,
          sensor_type: "celsius",
          sensor_index: "1002",
          sensor_oid: "1.3.6.1.2.1.99.1.1.1.4.1002",
          sensor_descr: "Chassis Temperature",
          sensor_unit: "°C",
          sensor_divisor: 1
        })
        |> Repo.insert()

      # Verify two sensors exist before rediscovery
      initial_sensor_count =
        Repo.aggregate(from(s in Sensor, where: s.snmp_device_id == ^snmp_device.id), :count)

      assert initial_sensor_count == 2

      # Mock SNMP walk responses - only return sensor 1001 (1002 is "removed")
      stub(SnmpMock, :walk, fn _, oid, _ ->
        case oid do
          # ENTITY-SENSOR-MIB sensor type - only return sensor 1001 (1002 is "removed")
          "1.3.6.1.2.1.99.1.1.1.1" ->
            {:ok, [%{oid: "1.3.6.1.2.1.99.1.1.1.1.1001", value: 8}]}

          # ENTITY-MIB descriptions for sensor names
          "1.3.6.1.2.1.47.1.1.1.1.2" ->
            {:ok, [%{oid: "1.3.6.1.2.1.47.1.1.1.1.2.1001", value: "CPU Temperature"}]}

          "1.3.6.1.2.1.47.1.1.1.1.7" ->
            {:ok, [%{oid: "1.3.6.1.2.1.47.1.1.1.1.7.1001", value: "TempSensor1"}]}

          "1.3.6.1.2.1.47.1.1.1.1.5" ->
            {:ok, [%{oid: "1.3.6.1.2.1.47.1.1.1.1.5.1001", value: 8}]}

          _ ->
            {:ok, []}
        end
      end)

      # Mock the individual sensor GETs for Base profile (ENTITY-SENSOR-MIB)
      stub(SnmpMock, :get, fn _, oid, _ ->
        case oid do
          "1.3.6.1.2.1.1.3.0" -> {:ok, {:timeticks, 54_321}}
          "1.3.6.1.2.1.1.1.0" -> {:ok, {:octet_string, "Cisco IOS Software, C2960"}}
          "1.3.6.1.2.1.1.2.0" -> {:ok, {:object_identifier, [1, 3, 6, 1, 4, 1, 9, 1, 1208]}}
          "1.3.6.1.2.1.1.4.0" -> {:ok, {:octet_string, "admin@example.com"}}
          "1.3.6.1.2.1.1.5.0" -> {:ok, {:octet_string, "test-switch"}}
          "1.3.6.1.2.1.1.6.0" -> {:ok, {:octet_string, "Data Center"}}
          # ENTITY-SENSOR-MIB sensor details for index 1001 only
          "1.3.6.1.2.1.99.1.1.1.1.1001" -> {:ok, {:integer, 8}}
          "1.3.6.1.2.1.99.1.1.1.2.1001" -> {:ok, {:integer, 9}}
          "1.3.6.1.2.1.99.1.1.1.3.1001" -> {:ok, {:integer, 0}}
          "1.3.6.1.2.1.99.1.1.1.4.1001" -> {:ok, {:integer, 47}}
          "1.3.6.1.2.1.99.1.1.1.5.1001" -> {:ok, {:integer, 1}}
          _ -> {:error, :no_such_object}
        end
      end)

      # Run rediscovery
      assert {:ok, _rediscovered_device} = Discovery.discover_device(device)

      # Verify only sensor 1001 remains
      sensors = Repo.all(from s in Sensor, where: s.snmp_device_id == ^snmp_device.id)
      assert length(sensors) == 1
      [remaining_sensor] = sensors

      # The preserved sensor should be sensor1 (index 1001)
      assert remaining_sensor.id == sensor1.id
      assert remaining_sensor.sensor_index == "1001"
    end
  end

  describe "discover_all/1" do
    test "discovers multiple devices concurrently", %{organization: organization, site: site} do
      # Create multiple SNMP-enabled devices
      {:ok, _device1} =
        Towerops.Devices.create_device(%{
          name: "Device 1",
          ip_address: "192.168.1.10",
          site_id: site.id,
          organization_id: organization.id,
          snmp_enabled: true,
          snmp_version: "2c",
          snmp_community: "public",
          snmp_port: 161
        })

      {:ok, _device2} =
        Towerops.Devices.create_device(%{
          name: "Device 2",
          ip_address: "192.168.1.11",
          site_id: site.id,
          organization_id: organization.id,
          snmp_enabled: true,
          snmp_version: "2c",
          snmp_community: "public",
          snmp_port: 161
        })

      {:ok, _device3} =
        Towerops.Devices.create_device(%{
          name: "Device 3 - No SNMP",
          ip_address: "192.168.1.12",
          site_id: site.id,
          organization_id: organization.id,
          snmp_enabled: false
        })

      # Mock successful discovery for device1
      allow(SnmpMock, self(), fn -> :ok end)

      stub(SnmpMock, :get, fn _target, oid, _opts ->
        case oid do
          "1.3.6.1.2.1.1.3.0" ->
            {:ok, {:timeticks, 12_345}}

          "1.3.6.1.2.1.1.1.0" ->
            {:ok, {:octet_string, "Test Device"}}

          _ ->
            {:ok, {:octet_string, ""}}
        end
      end)

      stub(SnmpMock, :walk, fn _, _, _ ->
        {:ok, []}
      end)

      assert {:ok, summary} = Discovery.discover_all(organization.id)

      # Should enqueue jobs for SNMP-enabled devices
      assert summary.enqueued == 2
      assert summary.failed == 0
    end

    test "handles mixed success and failure", %{organization: organization, site: site} do
      {:ok, _device1} =
        Towerops.Devices.create_device(%{
          name: "Good Device",
          ip_address: "192.168.1.20",
          site_id: site.id,
          organization_id: organization.id,
          snmp_enabled: true,
          snmp_version: "2c",
          snmp_community: "public",
          snmp_port: 161
        })

      {:ok, _device2} =
        Towerops.Devices.create_device(%{
          name: "Bad Device",
          ip_address: "192.168.1.21",
          site_id: site.id,
          organization_id: organization.id,
          snmp_enabled: true,
          snmp_version: "2c",
          snmp_community: "wrong",
          snmp_port: 161
        })

      allow(SnmpMock, self(), fn -> :ok end)

      stub(SnmpMock, :get, fn target, oid, _opts ->
        # Fail for .21, succeed for .20
        if target == "192.168.1.21" do
          {:error, :auth_failure}
        else
          case oid do
            "1.3.6.1.2.1.1.3.0" -> {:ok, {:timeticks, 12_345}}
            "1.3.6.1.2.1.1.1.0" -> {:ok, {:octet_string, "Good Device"}}
            _ -> {:ok, {:octet_string, ""}}
          end
        end
      end)

      stub(SnmpMock, :walk, fn target, _, _ ->
        if target == "192.168.1.21" do
          {:error, :auth_failure}
        else
          {:ok, []}
        end
      end)

      assert {:ok, summary} = Discovery.discover_all(organization.id)

      # Both devices should be enqueued (Oban jobs will handle success/failure)
      assert summary.enqueued == 2
      assert summary.failed == 0
    end

    test "updates device name from SNMP sysName when device name is empty", %{
      organization: organization,
      site: site
    } do
      # Create device without a name
      {:ok, device} =
        Towerops.Devices.create_device(%{
          name: nil,
          ip_address: "192.168.1.200",
          site_id: site.id,
          organization_id: organization.id,
          snmp_enabled: true,
          snmp_version: "2c",
          snmp_community: "public",
          snmp_port: 161
        })

      # Mock all SNMP get calls (categorize_device_speed + connection test + system info + UCD sensors)
      stub(SnmpMock, :get, fn _, oid, _ ->
        case oid do
          "1.3.6.1.2.1.1.1.0" ->
            {:ok, {:octet_string, "Linux switch01 4.4.0"}}

          "1.3.6.1.2.1.1.2.0" ->
            {:ok, {:object_identifier, [1, 3, 6, 1, 4, 1, 8072, 3, 2, 10]}}

          "1.3.6.1.2.1.1.3.0" ->
            {:ok, {:timeticks, 12_345}}

          "1.3.6.1.2.1.1.4.0" ->
            {:ok, {:octet_string, "admin@example.com"}}

          "1.3.6.1.2.1.1.5.0" ->
            {:ok, {:octet_string, "discovered-switch-name"}}

          "1.3.6.1.2.1.1.6.0" ->
            {:ok, {:octet_string, "Server Room"}}

          _ ->
            {:error, :no_such_object}
        end
      end)

      # Mock interface and sensor discovery
      stub(SnmpMock, :walk, fn _, _, _ ->
        {:ok, []}
      end)

      assert {:ok, _snmp_device} = Discovery.discover_device(device)

      # Verify the device name was updated with sysName
      updated_device = Towerops.Devices.get_device!(device.id)
      assert updated_device.name == "discovered-switch-name"
    end

    test "does not update device name from SNMP when device already has a name", %{
      organization: organization,
      site: site
    } do
      # Create device with a name
      {:ok, device} =
        Towerops.Devices.create_device(%{
          name: "My Custom Name",
          ip_address: "192.168.1.201",
          site_id: site.id,
          organization_id: organization.id,
          snmp_enabled: true,
          snmp_version: "2c",
          snmp_community: "public",
          snmp_port: 161
        })

      # Mock all SNMP get calls (categorize_device_speed + test_connection + system_info + UCD sensors)
      stub(SnmpMock, :get, fn _, oid, _ ->
        case oid do
          "1.3.6.1.2.1.1.1.0" ->
            {:ok, {:octet_string, "Linux switch02 4.4.0"}}

          "1.3.6.1.2.1.1.2.0" ->
            {:ok, {:object_identifier, [1, 3, 6, 1, 4, 1, 8072, 3, 2, 10]}}

          "1.3.6.1.2.1.1.3.0" ->
            {:ok, {:timeticks, 12_345}}

          "1.3.6.1.2.1.1.4.0" ->
            {:ok, {:octet_string, "admin@example.com"}}

          "1.3.6.1.2.1.1.5.0" ->
            {:ok, {:octet_string, "different-switch-name"}}

          "1.3.6.1.2.1.1.6.0" ->
            {:ok, {:octet_string, "Server Room"}}

          _ ->
            {:error, :no_such_object}
        end
      end)

      # Mock interface and sensor discovery
      # Walk called 15 times:
      # - LM sensors: temp devices (1), temp values (1), fan devices (1), fan values (1),
      #   voltage devices (1), voltage values (1)
      # - Base sensors fallback (1)
      # Mock interface and sensor discovery
      stub(SnmpMock, :walk, fn _, _, _ ->
        {:ok, []}
      end)

      assert {:ok, _snmp_device} = Discovery.discover_device(device)

      # Verify the device name was NOT changed
      updated_device = Towerops.Devices.get_device!(device.id)
      assert updated_device.name == "My Custom Name"
    end
  end
end
