defmodule Towerops.Snmp.ArpDiscovery do
  @moduledoc """
  Discovers ARP table entries via SNMP.

  Uses ipNetToMediaTable (1.3.6.1.2.1.4.22) from IP-MIB to retrieve
  IP-to-MAC address mappings from network devices.
  """

  alias Towerops.Snmp.Client

  require Logger

  @type arp_entry :: %{
          required(:ip_address) => String.t(),
          required(:mac_address) => String.t(),
          required(:if_index) => integer(),
          optional(:entry_type) => String.t(),
          required(:last_seen_at) => DateTime.t()
        }

  # ipNetToMediaTable OIDs (IP-MIB)
  # Index: ipNetToMediaIfIndex.ipNetToMediaNetAddress
  @ip_net_to_media_phys_address "1.3.6.1.2.1.4.22.1.2"
  @ip_net_to_media_type "1.3.6.1.2.1.4.22.1.4"

  @doc """
  Discovers ARP table entries for a device.
  Returns {:ok, arp_entries} where arp_entries is a list of arp_entry maps.
  """
  @spec discover_arp_table(Client.connection_opts()) :: {:ok, [arp_entry()]}
  def discover_arp_table(client_opts) do
    mac_addresses = fetch_arp_mac_addresses(client_opts)
    entry_types = fetch_arp_entry_types(client_opts)

    arp_entries =
      mac_addresses
      |> Enum.map(fn {key, mac} ->
        {if_index, ip_address} = parse_arp_key(key)
        entry_type = Map.get(entry_types, key)

        %{
          ip_address: ip_address,
          mac_address: format_mac_address(mac),
          if_index: if_index,
          entry_type: entry_type_to_string(entry_type),
          last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
        }
      end)
      |> Enum.reject(&is_nil(&1.ip_address))

    {:ok, arp_entries}
  end

  # Fetch MAC addresses from ipNetToMediaPhysAddress
  defp fetch_arp_mac_addresses(client_opts) do
    case Client.walk(client_opts, @ip_net_to_media_phys_address) do
      {:ok, entries} when map_size(entries) > 0 ->
        # Convert OID keys to just the index part (if_index.ip_address)
        Map.new(entries, fn {oid, value} ->
          key = extract_arp_index(oid, @ip_net_to_media_phys_address)
          {key, value}
        end)

      {:ok, _} ->
        Logger.debug("No ARP entries found")
        %{}

      {:error, reason} ->
        Logger.debug("ARP table discovery failed: #{inspect(reason)}")
        %{}
    end
  end

  # Fetch entry types from ipNetToMediaType
  defp fetch_arp_entry_types(client_opts) do
    case Client.walk(client_opts, @ip_net_to_media_type) do
      {:ok, entries} when map_size(entries) > 0 ->
        Map.new(entries, fn {oid, value} ->
          key = extract_arp_index(oid, @ip_net_to_media_type)
          {key, value}
        end)

      _ ->
        %{}
    end
  end

  # Extract the index portion from an OID (if_index.ip_address)
  defp extract_arp_index(oid, base_oid) do
    # Remove the base OID prefix and the trailing "."
    base_len = String.length(base_oid) + 1
    String.slice(oid, base_len..-1//1)
  end

  # Parse the ARP table index into if_index and IP address
  # Index format: if_index.ip_octet1.ip_octet2.ip_octet3.ip_octet4
  defp parse_arp_key(key) do
    case String.split(key, ".") do
      [if_index | ip_parts] when length(ip_parts) == 4 ->
        ip_address = Enum.join(ip_parts, ".")
        {String.to_integer(if_index), ip_address}

      _ ->
        {nil, nil}
    end
  end

  # Format MAC address from binary to colon-separated hex
  defp format_mac_address(value) when is_binary(value) and byte_size(value) == 6 do
    value
    |> :binary.bin_to_list()
    |> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
    |> String.downcase()
  end

  defp format_mac_address(value) when is_binary(value) do
    # Already formatted or different format
    if String.contains?(value, ":") or String.contains?(value, "-") do
      String.downcase(value)
    else
      # Try to convert hex string
      value
      |> :binary.bin_to_list()
      |> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
      |> String.downcase()
    end
  end

  defp format_mac_address(_), do: nil

  # Convert ipNetToMediaType integer to string
  # 1 = other, 2 = invalid, 3 = dynamic, 4 = static
  defp entry_type_to_string(1), do: "other"
  defp entry_type_to_string(2), do: "invalid"
  defp entry_type_to_string(3), do: "dynamic"
  defp entry_type_to_string(4), do: "static"
  defp entry_type_to_string(_), do: nil
end
