defmodule Towerops.Snmp.ClientTest do
  use ExUnit.Case, async: true
  use ExUnitProperties

  import Mox

  alias Towerops.Snmp.Client
  alias Towerops.Snmp.SnmpMock

  # Set up mocks
  setup :verify_on_exit!

  @test_opts [ip: "192.168.1.1", community: "public", version: "2c"]

  describe "get/2" do
    test "returns ok tuple with extracted value on success" do
      expect(SnmpMock, :get, fn "192.168.1.1", "1.3.6.1.2.1.1.3.0", _opts ->
        {:ok, {:timeticks, 12_345}}
      end)

      assert {:ok, 12_345} = Client.get(@test_opts, "1.3.6.1.2.1.1.3.0")
    end

    test "extracts value from different SNMP types" do
      expect(SnmpMock, :get, fn _, _, _ -> {:ok, {:octet_string, "test"}} end)
      assert {:ok, "test"} = Client.get(@test_opts, "1.3.6.1.2.1.1.1.0")
    end

    test "returns error tuple on failure" do
      expect(SnmpMock, :get, fn _, _, _ -> {:error, :timeout} end)
      assert {:error, :timeout} = Client.get(@test_opts, "1.3.6.1.2.1.1.3.0")
    end

    test "handles no_such_object error" do
      expect(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
      assert {:error, :no_such_object} = Client.get(@test_opts, "1.3.6.1.99.99.99")
    end

    test "handles no_such_instance error" do
      expect(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_instance} end)
      assert {:error, :no_such_instance} = Client.get(@test_opts, "1.3.6.1.2.1.1.99.0")
    end

    test "handles end_of_mib_view error" do
      expect(SnmpMock, :get, fn _, _, _ -> {:error, :end_of_mib_view} end)
      assert {:error, :end_of_mib_view} = Client.get(@test_opts, "1.3.6.1.9.9.9.9")
    end

    test "accepts list OID format" do
      expect(SnmpMock, :get, fn _, [1, 3, 6, 1, 2, 1, 1, 3, 0], _ ->
        {:ok, {:timeticks, 12_345}}
      end)

      assert {:ok, 12_345} = Client.get(@test_opts, [1, 3, 6, 1, 2, 1, 1, 3, 0])
    end

    property "extracts all SNMP value types correctly" do
      check all(
              {type, value} <-
                StreamData.one_of([
                  StreamData.tuple({
                    StreamData.constant(:integer),
                    StreamData.integer()
                  }),
                  StreamData.tuple({
                    StreamData.constant(:octet_string),
                    StreamData.string(:alphanumeric)
                  }),
                  StreamData.tuple({
                    StreamData.constant(:counter32),
                    StreamData.integer(0..4_294_967_295)
                  }),
                  StreamData.tuple({
                    StreamData.constant(:gauge32),
                    StreamData.integer(0..4_294_967_295)
                  })
                ])
            ) do
        expect(SnmpMock, :get, fn _, _, _ -> {:ok, {type, value}} end)
        assert {:ok, ^value} = Client.get(@test_opts, "1.3.6.1.2.1.1.1.0")
      end
    end
  end

  describe "get_multiple/2" do
    test "returns ok with list of values when all succeed" do
      expect(SnmpMock, :get, 2, fn _, oid, _ ->
        case oid do
          "1.3.6.1.2.1.1.1.0" -> {:ok, {:octet_string, "Router"}}
          "1.3.6.1.2.1.1.3.0" -> {:ok, {:timeticks, 12_345}}
        end
      end)

      oids = ["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.3.0"]
      assert {:ok, ["Router", 12_345]} = Client.get_multiple(@test_opts, oids)
    end

    test "returns error when any OID fails" do
      expect(SnmpMock, :get, 2, fn _, oid, _ ->
        case oid do
          "1.3.6.1.2.1.1.1.0" -> {:ok, {:octet_string, "Router"}}
          "1.3.6.1.2.1.99.99.0" -> {:error, :no_such_object}
        end
      end)

      oids = ["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.99.99.0"]
      assert {:error, :partial_failure} = Client.get_multiple(@test_opts, oids)
    end

    property "handles variable number of OIDs" do
      check all(count <- StreamData.integer(1..10)) do
        expect(SnmpMock, :get, count, fn _, _, _ ->
          {:ok, {:integer, 42}}
        end)

        oids = Enum.map(1..count, fn i -> "1.3.6.1.2.1.1.#{i}.0" end)
        assert {:ok, values} = Client.get_multiple(@test_opts, oids)
        assert length(values) == count
        assert Enum.all?(values, &(&1 == 42))
      end
    end
  end

  describe "walk/2" do
    test "returns map of OID to value on success" do
      expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.2.2.1.2", _ ->
        {:ok,
         [
           %{oid: "1.3.6.1.2.1.2.2.1.2.1", value: "eth0"},
           %{oid: "1.3.6.1.2.1.2.2.1.2.2", value: "eth1"}
         ]}
      end)

      assert {:ok, results} = Client.walk(@test_opts, "1.3.6.1.2.1.2.2.1.2")
      assert is_map(results)
      assert results["1.3.6.1.2.1.2.2.1.2.1"] == "eth0"
      assert results["1.3.6.1.2.1.2.2.1.2.2"] == "eth1"
    end

    test "returns error tuple on failure" do
      expect(SnmpMock, :walk, fn _, _, _ -> {:error, :timeout} end)
      assert {:error, :timeout} = Client.walk(@test_opts, "1.3.6.1.2.1.2.2.1.2")
    end

    test "handles empty walk results" do
      expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
      assert {:ok, results} = Client.walk(@test_opts, "1.3.6.1.2.1.99.99")
      assert results == %{}
    end

    property "converts list of results to map" do
      check all(count <- StreamData.integer(1..20)) do
        results =
          Enum.map(1..count, fn i ->
            %{oid: "1.3.6.1.2.1.2.2.1.2.#{i}", value: "eth#{i}"}
          end)

        expect(SnmpMock, :walk, fn _, _, _ -> {:ok, results} end)
        assert {:ok, walked} = Client.walk(@test_opts, "1.3.6.1.2.1.2.2.1.2")
        assert map_size(walked) == count
      end
    end
  end

  describe "get_bulk/3" do
    test "returns map of OID to value on success" do
      expect(SnmpMock, :get_bulk, fn _, "1.3.6.1.2.1.2.2.1.10", opts ->
        assert opts[:max_repetitions] == 10

        {:ok,
         [
           %{oid: "1.3.6.1.2.1.2.2.1.10.1", value: 1000},
           %{oid: "1.3.6.1.2.1.2.2.1.10.2", value: 2000}
         ]}
      end)

      assert {:ok, results} = Client.get_bulk(@test_opts, "1.3.6.1.2.1.2.2.1.10", max_repetitions: 10)
      assert is_map(results)
      assert results["1.3.6.1.2.1.2.2.1.10.1"] == 1000
    end

    test "uses default max_repetitions of 10" do
      expect(SnmpMock, :get_bulk, fn _, _, opts ->
        assert opts[:max_repetitions] == 10
        {:ok, []}
      end)

      Client.get_bulk(@test_opts, "1.3.6.1.2.1.2.2.1.10")
    end

    test "uses custom max_repetitions" do
      expect(SnmpMock, :get_bulk, fn _, _, opts ->
        assert opts[:max_repetitions] == 50
        {:ok, []}
      end)

      Client.get_bulk(@test_opts, "1.3.6.1.2.1.2.2.1.10", max_repetitions: 50)
    end

    test "returns error tuple on failure" do
      expect(SnmpMock, :get_bulk, fn _, _, _ -> {:error, :timeout} end)
      assert {:error, :timeout} = Client.get_bulk(@test_opts, "1.3.6.1.2.1.2.2.1.10")
    end
  end

  describe "test_connection/1" do
    test "returns success when sysUpTime is retrieved" do
      expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
        {:ok, {:timeticks, 12_345}}
      end)

      assert {:ok, "Connection successful"} = Client.test_connection(@test_opts)
    end

    test "returns error when connection fails" do
      expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
        {:error, :timeout}
      end)

      assert {:error, :timeout} = Client.test_connection(@test_opts)
    end

    test "returns error when host is unreachable" do
      expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
        {:error, :network_unreachable}
      end)

      assert {:error, :network_unreachable} = Client.test_connection(@test_opts)
    end
  end

  describe "connection options" do
    property "valid connection options include all required fields" do
      check all(
              ip <- StreamData.string(:alphanumeric, min_length: 7, max_length: 15),
              community <- StreamData.string(:alphanumeric, min_length: 1, max_length: 32),
              version <- StreamData.member_of(["1", "2c"]),
              port <- StreamData.integer(1..65_535),
              timeout <- StreamData.integer(1000..30_000)
            ) do
        opts = [
          ip: ip,
          community: community,
          version: version,
          port: port,
          timeout: timeout
        ]

        # Should have all required fields
        assert Keyword.has_key?(opts, :ip)
        assert Keyword.has_key?(opts, :community)
        assert Keyword.has_key?(opts, :version)

        # Optional fields with valid values
        assert opts[:port] > 0 and opts[:port] <= 65_535
        assert opts[:timeout] >= 1000
      end
    end

    test "minimal valid options" do
      opts = [ip: "192.168.1.1", community: "public", version: "2c"]

      assert Keyword.has_key?(opts, :ip)
      assert Keyword.has_key?(opts, :community)
      assert Keyword.has_key?(opts, :version)
    end
  end

  describe "OID formats" do
    property "accepts string OIDs" do
      check all(oid_parts <- StreamData.list_of(StreamData.positive_integer(), min_length: 3, max_length: 10)) do
        oid = Enum.join(oid_parts, ".")

        # Valid OID format
        assert oid =~ ~r/^[\d.]+$/
        assert is_binary(oid)
      end
    end

    property "accepts list OIDs" do
      check all(oid_list <- StreamData.list_of(StreamData.non_negative_integer(), min_length: 3, max_length: 10)) do
        # All elements are integers
        assert Enum.all?(oid_list, &is_integer/1)

        # All elements are non-negative
        assert Enum.all?(oid_list, &(&1 >= 0))
      end
    end
  end

  describe "IP address formats" do
    property "IP addresses are strings" do
      check all(octets <- StreamData.list_of(StreamData.integer(0..255), length: 4)) do
        ip = Enum.join(octets, ".")
        opts = [ip: ip, community: "public", version: "2c"]

        assert is_binary(opts[:ip])
      end
    end

    test "IPv4 format" do
      valid_ips = [
        "192.168.1.1",
        "10.0.0.1",
        "172.16.0.1",
        "8.8.8.8",
        "127.0.0.1"
      ]

      for ip <- valid_ips do
        opts = [ip: ip, community: "public", version: "2c"]
        assert opts[:ip] == ip
      end
    end

    test "hostname format" do
      valid_hosts = [
        "router.example.com",
        "switch-01",
        "device123"
      ]

      for host <- valid_hosts do
        opts = [ip: host, community: "public", version: "2c"]
        assert opts[:ip] == host
      end
    end
  end

  describe "SNMP version handling" do
    test "supports SNMPv1" do
      opts = [ip: "192.168.1.1", community: "public", version: "1"]
      assert opts[:version] == "1"
    end

    test "supports SNMPv2c" do
      opts = [ip: "192.168.1.1", community: "public", version: "2c"]
      assert opts[:version] == "2c"
    end

    property "version is always v1 or v2c" do
      check all(version <- StreamData.member_of(["1", "2c", :v1, :v2c])) do
        opts = [ip: "192.168.1.1", community: "public", version: version]

        normalized_version =
          case opts[:version] do
            "1" -> :v1
            "2c" -> :v2c
            :v1 -> :v1
            :v2c -> :v2c
          end

        assert normalized_version in [:v1, :v2c]
      end
    end
  end

  describe "community strings" do
    property "community strings can be any non-empty string" do
      check all(community <- StreamData.string(:alphanumeric, min_length: 1, max_length: 64)) do
        opts = [ip: "192.168.1.1", community: community, version: "2c"]

        assert is_binary(opts[:community])
        assert String.length(opts[:community]) > 0
      end
    end

    test "common community strings" do
      common_communities = ["public", "private", "community", "snmp"]

      for community <- common_communities do
        opts = [ip: "192.168.1.1", community: community, version: "2c"]
        assert opts[:community] == community
      end
    end
  end

  describe "return value formats" do
    property "error reasons are atoms" do
      check all(
              reason <-
                StreamData.member_of([
                  :timeout,
                  :no_such_object,
                  :no_such_instance,
                  :end_of_mib_view,
                  :network_unreachable
                ])
            ) do
        expect(SnmpMock, :get, fn _, _, _ -> {:error, reason} end)
        assert {:error, ^reason} = Client.get(@test_opts, "1.3.6.1.2.1.1.1.0")
      end
    end
  end
end
