defmodule Towerops.Snmp.NeighborDiscoveryTest do
  use Towerops.DataCase

  import Mox
  import Towerops.AccountsFixtures

  alias Towerops.Snmp
  alias Towerops.Snmp.Device
  alias Towerops.Snmp.Interface
  alias Towerops.Snmp.Neighbor
  alias Towerops.Snmp.NeighborDiscovery
  alias Towerops.Snmp.SnmpMock

  setup :verify_on_exit!

  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, device_schema} =
      Towerops.Devices.create_device(%{
        name: "Test Switch",
        ip_address: "192.168.1.1",
        snmp_enabled: true,
        snmp_version: "2c",
        snmp_community: "public",
        snmp_port: 161,
        site_id: site.id
      })

    device =
      %Device{}
      |> Device.changeset(%{
        device_id: device_schema.id,
        sys_name: "test-switch",
        sys_descr: "Test Switch"
      })
      |> Repo.insert!()

    interface1 =
      %Interface{}
      |> Interface.changeset(%{
        snmp_device_id: device.id,
        if_index: 1,
        if_descr: "GigabitEthernet0/1",
        if_name: "Gi0/1",
        if_type: 6,
        if_oper_status: "up"
      })
      |> Repo.insert!()

    interface2 =
      %Interface{}
      |> Interface.changeset(%{
        snmp_device_id: device.id,
        if_index: 2,
        if_descr: "GigabitEthernet0/2",
        if_name: "Gi0/2",
        if_type: 6,
        if_oper_status: "up"
      })
      |> Repo.insert!()

    %{
      device_schema: device_schema,
      device: device,
      interface1: interface1,
      interface2: interface2,
      organization: organization
    }
  end

  describe "discover_neighbors/2" do
    test "discovers LLDP neighbors", %{device_schema: device_schema, device: device, interface1: interface1} do
      # Mock LLDP walk response
      expect(SnmpMock, :walk, fn _, _, _ ->
        {:ok,
         [
           # LLDP remote chassis ID (field 5) for timemark.1.1
           %{oid: "1.0.8802.1.1.2.1.4.1.1.5.0.1.1", value: <<0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF>>},
           # LLDP remote port ID (field 7)
           %{oid: "1.0.8802.1.1.2.1.4.1.1.7.0.1.1", value: "Gi0/1"},
           # LLDP remote port description (field 8)
           %{oid: "1.0.8802.1.1.2.1.4.1.1.8.0.1.1", value: "GigabitEthernet0/1"},
           # LLDP remote system name (field 9)
           %{oid: "1.0.8802.1.1.2.1.4.1.1.9.0.1.1", value: "neighbor-switch"},
           # LLDP remote system description (field 10)
           %{oid: "1.0.8802.1.1.2.1.4.1.1.10.0.1.1", value: "Cisco IOS Software"}
         ]}
      end)

      # Mock CDP walk response (empty)
      expect(SnmpMock, :walk, fn _, _, _ ->
        {:ok, []}
      end)

      client_opts = [
        ip: device_schema.ip_address,
        community: device_schema.snmp_community,
        version: device_schema.snmp_version,
        port: device_schema.snmp_port
      ]

      interfaces = [Map.put(interface1, :device_id, device_schema.id)]

      {:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)

      assert length(neighbors) == 1
      neighbor = List.first(neighbors)

      assert neighbor.protocol == "lldp"
      assert neighbor.interface_id == interface1.id
      assert neighbor.device_id == device_schema.id
      assert neighbor.remote_system_name == "neighbor-switch"
      assert neighbor.remote_port_id == "Gi0/1"
      assert neighbor.remote_port_description == "GigabitEthernet0/1"
      assert neighbor.remote_system_description == "Cisco IOS Software"
    end

    test "discovers CDP neighbors", %{device_schema: device_schema, device: device, interface2: interface2} do
      # Mock LLDP walk response (empty)
      expect(SnmpMock, :walk, fn _, _, _ ->
        {:ok, []}
      end)

      # Mock CDP walk response
      expect(SnmpMock, :walk, fn _, _, _ ->
        {:ok,
         [
           # CDP remote address (field 4) for if_index.2.device_index.1
           %{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.4.2.1", value: <<192, 168, 1, 10>>},
           # CDP remote device ID (field 6)
           %{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.6.2.1", value: "neighbor-router.example.com"},
           # CDP remote port (field 7)
           %{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.7.2.1", value: "FastEthernet0/1"},
           # CDP remote platform (field 8)
           %{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.8.2.1", value: "cisco ISR4331/K9"}
         ]}
      end)

      client_opts = [
        ip: device_schema.ip_address,
        community: device_schema.snmp_community,
        version: device_schema.snmp_version,
        port: device_schema.snmp_port
      ]

      interfaces = [Map.put(interface2, :device_id, device_schema.id)]

      {:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)

      assert length(neighbors) == 1
      neighbor = List.first(neighbors)

      assert neighbor.protocol == "cdp"
      assert neighbor.interface_id == interface2.id
      assert neighbor.device_id == device_schema.id
      assert neighbor.remote_system_name == "neighbor-router.example.com"
      assert neighbor.remote_port_id == "FastEthernet0/1"
      assert neighbor.remote_platform == "cisco ISR4331/K9"
      assert neighbor.remote_address == "192.168.1.10"
    end

    test "discovers both LLDP and CDP neighbors", %{
      device_schema: device_schema,
      device: device,
      interface1: interface1,
      interface2: interface2
    } do
      # Mock LLDP walk response
      expect(SnmpMock, :walk, fn _, _, _ ->
        {:ok,
         [
           %{oid: "1.0.8802.1.1.2.1.4.1.1.4.0.1.1", value: <<0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF>>},
           %{oid: "1.0.8802.1.1.2.1.4.1.1.7.0.1.1", value: "lldp-neighbor"}
         ]}
      end)

      # Mock CDP walk response
      expect(SnmpMock, :walk, fn _, _, _ ->
        {:ok,
         [
           %{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.6.2.1", value: "cdp-neighbor"},
           %{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.7.2.1", value: "Gi0/1"}
         ]}
      end)

      client_opts = [
        ip: device_schema.ip_address,
        community: device_schema.snmp_community,
        version: device_schema.snmp_version,
        port: device_schema.snmp_port
      ]

      interfaces = [
        Map.put(interface1, :device_id, device_schema.id),
        Map.put(interface2, :device_id, device_schema.id)
      ]

      {:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)

      assert length(neighbors) == 2
      protocols = Enum.map(neighbors, & &1.protocol)
      assert "lldp" in protocols
      assert "cdp" in protocols
    end

    test "returns empty list when no neighbors found", %{device_schema: device_schema, device: device} do
      # Mock LLDP walk response (empty)
      expect(SnmpMock, :walk, fn _, _, _ ->
        {:ok, []}
      end)

      # Mock CDP walk response (empty)
      expect(SnmpMock, :walk, fn _, _, _ ->
        {:ok, []}
      end)

      client_opts = [
        ip: device_schema.ip_address,
        community: device_schema.snmp_community,
        version: device_schema.snmp_version,
        port: device_schema.snmp_port
      ]

      {:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, [])

      assert neighbors == []
    end

    test "handles SNMP errors gracefully", %{device_schema: device_schema, device: device, interface1: interface1} do
      # Mock LLDP walk error
      expect(SnmpMock, :walk, fn _, _, _ ->
        {:error, :timeout}
      end)

      # Mock CDP walk error
      expect(SnmpMock, :walk, fn _, _, _ ->
        {:error, :timeout}
      end)

      client_opts = [
        ip: device_schema.ip_address,
        community: device_schema.snmp_community,
        version: device_schema.snmp_version,
        port: device_schema.snmp_port
      ]

      interfaces = [Map.put(interface1, :device_id, device_schema.id)]

      {:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)

      # Should return empty list on error
      assert neighbors == []
    end
  end

  describe "upsert_neighbor/1" do
    test "creates a new neighbor record", %{device_schema: device_schema, device: device, interface1: interface1} do
      attrs = %{
        device_id: device_schema.id,
        interface_id: interface1.id,
        protocol: "lldp",
        remote_chassis_id: "aa:bb:cc:dd:ee:ff",
        remote_system_name: "test-neighbor",
        remote_system_description: "Test Device",
        remote_platform: "Test Platform",
        remote_port_id: "Gi0/1",
        remote_port_description: "GigabitEthernet0/1",
        remote_address: "192.168.1.100",
        remote_capabilities: ["router", "bridge"],
        last_discovered_at: DateTime.utc_now()
      }

      assert {:ok, neighbor} = Snmp.upsert_neighbor(attrs)
      assert neighbor.protocol == "lldp"
      assert neighbor.remote_system_name == "test-neighbor"
      assert neighbor.remote_chassis_id == "aa:bb:cc:dd:ee:ff"
      assert neighbor.remote_capabilities == ["router", "bridge"]
    end

    test "updates existing neighbor record", %{device_schema: device_schema, device: device, interface1: interface1} do
      attrs = %{
        device_id: device_schema.id,
        interface_id: interface1.id,
        protocol: "cdp",
        remote_chassis_id: "neighbor-switch",
        remote_system_name: "old-name",
        remote_platform: "Old Platform",
        last_discovered_at: DateTime.utc_now()
      }

      {:ok, original} = Snmp.upsert_neighbor(attrs)

      # Update with new data
      updated_attrs = %{
        device_id: device_schema.id,
        interface_id: interface1.id,
        protocol: "cdp",
        remote_chassis_id: "neighbor-switch",
        remote_system_name: "new-name",
        remote_platform: "New Platform",
        last_discovered_at: DateTime.utc_now()
      }

      {:ok, updated} = Snmp.upsert_neighbor(updated_attrs)

      # Should be the same record, just updated
      assert updated.id == original.id
      assert updated.remote_system_name == "new-name"
      assert updated.remote_platform == "New Platform"
    end

    test "creates separate records for different protocols", %{
      device_schema: device_schema,
      device: device,
      interface1: interface1
    } do
      # Create LLDP neighbor
      lldp_attrs = %{
        device_id: device_schema.id,
        interface_id: interface1.id,
        protocol: "lldp",
        remote_chassis_id: "same-chassis-id",
        remote_system_name: "neighbor-switch",
        last_discovered_at: DateTime.utc_now()
      }

      # Create CDP neighbor with same chassis ID
      cdp_attrs = %{
        device_id: device_schema.id,
        interface_id: interface1.id,
        protocol: "cdp",
        remote_chassis_id: "same-chassis-id",
        remote_system_name: "neighbor-switch",
        last_discovered_at: DateTime.utc_now()
      }

      {:ok, lldp_neighbor} = Snmp.upsert_neighbor(lldp_attrs)
      {:ok, cdp_neighbor} = Snmp.upsert_neighbor(cdp_attrs)

      # Should be different records
      assert lldp_neighbor.id != cdp_neighbor.id
      assert lldp_neighbor.protocol == "lldp"
      assert cdp_neighbor.protocol == "cdp"
    end
  end

  describe "list_neighbors/1" do
    test "returns all neighbors for equipment", %{
      device_schema: device_schema,
      device: device,
      interface1: interface1,
      interface2: interface2
    } do
      # Create neighbors on different interfaces
      {:ok, _neighbor1} =
        Snmp.upsert_neighbor(%{
          device_id: device_schema.id,
          interface_id: interface1.id,
          protocol: "lldp",
          remote_chassis_id: "aa:bb:cc:dd:ee:ff",
          remote_system_name: "neighbor1",
          last_discovered_at: DateTime.utc_now()
        })

      {:ok, _neighbor2} =
        Snmp.upsert_neighbor(%{
          device_id: device_schema.id,
          interface_id: interface2.id,
          protocol: "cdp",
          remote_chassis_id: "neighbor2",
          remote_system_name: "neighbor2",
          last_discovered_at: DateTime.utc_now()
        })

      neighbors = Snmp.list_neighbors(device_schema.id)

      assert length(neighbors) == 2
      assert Enum.all?(neighbors, &(&1.device_id == device_schema.id))
      # Check that interface is preloaded
      assert Enum.all?(neighbors, &Ecto.assoc_loaded?(&1.interface))
    end

    test "returns empty list for equipment with no neighbors", %{device_schema: device_schema, device: device} do
      neighbors = Snmp.list_neighbors(device_schema.id)
      assert neighbors == []
    end

    test "orders neighbors by protocol and system name", %{
      device_schema: device_schema,
      device: device,
      interface1: interface1
    } do
      # Create neighbors in reverse alphabetical order
      {:ok, _} =
        Snmp.upsert_neighbor(%{
          device_id: device_schema.id,
          interface_id: interface1.id,
          protocol: "lldp",
          remote_chassis_id: "chassis-z",
          remote_system_name: "zebra",
          last_discovered_at: DateTime.utc_now()
        })

      {:ok, _} =
        Snmp.upsert_neighbor(%{
          device_id: device_schema.id,
          interface_id: interface1.id,
          protocol: "cdp",
          remote_chassis_id: "chassis-a",
          remote_system_name: "alpha",
          last_discovered_at: DateTime.utc_now()
        })

      neighbors = Snmp.list_neighbors(device_schema.id)

      # Should be ordered by protocol, then system name
      assert length(neighbors) == 2
      [first, second] = neighbors
      assert first.protocol == "cdp"
      assert second.protocol == "lldp"
    end
  end

  describe "delete_stale_neighbors/2" do
    test "deletes neighbors not seen since cutoff", %{
      device_schema: device_schema,
      device: device,
      interface1: interface1
    } do
      # Create old neighbor
      two_days_ago = DateTime.add(DateTime.utc_now(), -48, :hour)

      {:ok, old_neighbor} =
        Snmp.upsert_neighbor(%{
          device_id: device_schema.id,
          interface_id: interface1.id,
          protocol: "lldp",
          remote_chassis_id: "old-neighbor",
          remote_system_name: "old-neighbor",
          last_discovered_at: two_days_ago
        })

      # Create recent neighbor
      {:ok, recent_neighbor} =
        Snmp.upsert_neighbor(%{
          device_id: device_schema.id,
          interface_id: interface1.id,
          protocol: "cdp",
          remote_chassis_id: "recent-neighbor",
          remote_system_name: "recent-neighbor",
          last_discovered_at: DateTime.utc_now()
        })

      # Delete neighbors older than 24 hours
      cutoff = DateTime.add(DateTime.utc_now(), -24, :hour)
      {count, _} = Snmp.delete_stale_neighbors(device_schema.id, cutoff)

      assert count == 1

      # Old neighbor should be deleted
      assert Repo.get(Neighbor, old_neighbor.id) == nil

      # Recent neighbor should still exist
      assert Repo.get(Neighbor, recent_neighbor.id)
    end

    test "only deletes neighbors for specified equipment", %{
      device_schema: device_schema,
      interface1: interface1,
      device: device
    } do
      user = user_fixture()
      {:ok, org} = Towerops.Organizations.create_organization(%{name: "Org 2"}, user.id)
      {:ok, site} = Towerops.Sites.create_site(%{name: "Site 2", organization_id: org.id})

      {:ok, equipment2} =
        Towerops.Devices.create_device(%{
          name: "Other Equipment",
          ip_address: "192.168.1.2",
          site_id: site.id
        })

      device2 =
        %Device{}
        |> Device.changeset(%{
          device_id: equipment2.id,
          sys_name: "other-device",
          sys_descr: "Other Device"
        })
        |> Repo.insert!()

      interface2 =
        %Interface{}
        |> Interface.changeset(%{
          snmp_device_id: device2.id,
          if_index: 1,
          if_descr: "eth0",
          if_type: 6
        })
        |> Repo.insert!()

      two_days_ago = DateTime.add(DateTime.utc_now(), -48, :hour)

      # Create old neighbors on both equipment
      {:ok, neighbor1} =
        Snmp.upsert_neighbor(%{
          device_id: device_schema.id,
          interface_id: interface1.id,
          protocol: "lldp",
          remote_chassis_id: "neighbor1",
          remote_system_name: "neighbor1",
          last_discovered_at: two_days_ago
        })

      {:ok, neighbor2} =
        Snmp.upsert_neighbor(%{
          device_id: equipment2.id,
          interface_id: interface2.id,
          protocol: "lldp",
          remote_chassis_id: "neighbor2",
          remote_system_name: "neighbor2",
          last_discovered_at: two_days_ago
        })

      # Delete only from first equipment
      cutoff = DateTime.add(DateTime.utc_now(), -24, :hour)
      {count, _} = Snmp.delete_stale_neighbors(device_schema.id, cutoff)

      assert count == 1

      # First neighbor should be deleted
      assert Repo.get(Neighbor, neighbor1.id) == nil

      # Second neighbor should still exist
      assert Repo.get(Neighbor, neighbor2.id)
    end

    test "returns count of 0 when no stale neighbors", %{device_schema: device_schema, device: device} do
      cutoff = DateTime.add(DateTime.utc_now(), -24, :hour)
      {count, _} = Snmp.delete_stale_neighbors(device_schema.id, cutoff)

      assert count == 0
    end
  end
end
