defmodule Towerops.Snmp.NeighborDiscovery do
  @moduledoc """
  Discovers network neighbors via LLDP and CDP protocols.

  Supports:
  - LLDP (Link Layer Discovery Protocol) via LLDP-MIB
  - CDP (Cisco Discovery Protocol) via CISCO-CDP-MIB
  """

  alias Towerops.Snmp.Client
  alias Towerops.Snmp.Sanitizer

  require Logger

  @type neighbor_data :: %{
          required(:protocol) => String.t(),
          required(:interface_id) => binary(),
          required(:device_id) => binary(),
          optional(:remote_chassis_id) => String.t(),
          optional(:remote_system_name) => String.t(),
          optional(:remote_system_description) => String.t(),
          optional(:remote_platform) => String.t(),
          optional(:remote_port_id) => String.t(),
          optional(:remote_port_description) => String.t(),
          optional(:remote_address) => String.t(),
          optional(:remote_capabilities) => [String.t()],
          required(:last_discovered_at) => DateTime.t()
        }

  @doc """
  Discovers all neighbors (LLDP and CDP) for a device.
  Returns {:ok, neighbors} where neighbors is a list of neighbor_data maps.
  """
  @spec discover_neighbors(Client.connection_opts(), list(map())) ::
          {:ok, [neighbor_data()]}
  def discover_neighbors(client_opts, interfaces) do
    lldp_neighbors = discover_lldp_neighbors(client_opts, interfaces)
    cdp_neighbors = discover_cdp_neighbors(client_opts, interfaces)

    {:ok, lldp_neighbors ++ cdp_neighbors}
  end

  # LLDP Discovery (IEEE 802.1AB)

  @lldp_rem_table_oid "1.0.8802.1.1.2.1.4.1.1"
  @lldp_rem_man_addr_table_oid "1.0.8802.1.1.2.1.4.2"

  defp discover_lldp_neighbors(client_opts, interfaces) do
    case Client.walk(client_opts, @lldp_rem_table_oid) do
      {:ok, entries} when entries != [] ->
        # Fetch management addresses separately
        mgmt_addresses = fetch_lldp_management_addresses(client_opts)
        parse_lldp_neighbors(entries, interfaces, mgmt_addresses)

      {:ok, entries} when entries == %{} ->
        Logger.debug("No LLDP neighbors found")
        []

      {:error, reason} ->
        Logger.debug("LLDP discovery failed: #{inspect(reason)}")
        []
    end
  end

  # Fetch LLDP management addresses from lldpRemManAddrTable
  # Returns a map of "local_port.rem_index" => ip_address
  defp fetch_lldp_management_addresses(client_opts) do
    case Client.walk(client_opts, @lldp_rem_man_addr_table_oid) do
      {:ok, entries} when entries != [] ->
        parse_lldp_management_addresses(entries)

      _ ->
        %{}
    end
  end

  # Parse management addresses from the lldpRemManAddrTable
  # OID format: 1.0.8802.1.1.2.1.4.2.1.{field}.{time}.{local_port}.{rem_index}.{addr_subtype}.{addr_len}.{addr_bytes...}
  defp parse_lldp_management_addresses(entries) do
    entries
    |> Enum.map(fn {oid, _value} -> parse_management_address_oid(oid) end)
    |> Enum.reject(&is_nil/1)
    |> Enum.reduce(%{}, fn {key, addr}, acc ->
      # Only keep first address per neighbor (prefer not overwriting)
      Map.put_new(acc, key, addr)
    end)
  end

  # Parse the management address from the OID index
  # Returns {neighbor_key, ip_address} or nil
  defp parse_management_address_oid(oid) do
    parts = String.split(oid, ".")

    # OID structure after base: field.time.local_port.rem_index.addr_subtype.addr_len.addr_bytes...
    # Base OID is 1.0.8802.1.1.2.1.4.2.1 (10 parts)
    case Enum.drop(parts, 10) do
      [_field, _time, local_port, rem_index, addr_subtype | addr_parts] ->
        parse_management_address(local_port, rem_index, addr_subtype, addr_parts)

      _ ->
        nil
    end
  end

  # Parse IPv4 management address (addr_subtype = 1)
  defp parse_management_address(local_port, rem_index, "1", [_len | addr_bytes]) when length(addr_bytes) >= 4 do
    # Take first 4 bytes for IPv4
    ip =
      addr_bytes
      |> Enum.take(4)
      |> Enum.join(".")

    {"#{local_port}.#{rem_index}", ip}
  end

  # Parse IPv6 management address (addr_subtype = 2)
  defp parse_management_address(local_port, rem_index, "2", [_len | addr_bytes]) when length(addr_bytes) >= 16 do
    # Format IPv6 address
    ip =
      addr_bytes
      |> Enum.take(16)
      |> Enum.chunk_every(2)
      |> Enum.map_join(":", fn [a, b] ->
        Integer.to_string(String.to_integer(a) * 256 + String.to_integer(b), 16)
      end)

    {"#{local_port}.#{rem_index}", ip}
  end

  # Unknown address type or malformed
  defp parse_management_address(_local_port, _rem_index, _addr_subtype, _addr_parts), do: nil

  defp parse_lldp_neighbors(entries, interfaces, mgmt_addresses) do
    # Convert map from Client.walk to list of maps
    entry_list = Enum.map(entries, fn {oid, value} -> %{oid: oid, value: value} end)

    # Group by local port index (time, local_port_num, rem_index)
    # OIDs are in format: 1.0.8802.1.1.2.1.4.1.1.X.time.local_port.rem_index
    grouped =
      Enum.group_by(entry_list, fn %{oid: oid} ->
        # Extract local port num (second to last index) and remote index (last index)
        parts = String.split(oid, ".")
        [rem_index, local_port, _time | _] = Enum.reverse(parts)
        "#{local_port}.#{rem_index}"
      end)

    grouped
    |> Enum.map(fn {neighbor_key, neighbor_entries} ->
      build_lldp_neighbor(neighbor_entries, interfaces, neighbor_key, mgmt_addresses)
    end)
    |> Enum.reject(&is_nil/1)
  end

  defp build_lldp_neighbor(entries, interfaces, neighbor_key, mgmt_addresses) do
    neighbor_map =
      Enum.reduce(entries, %{}, fn %{oid: oid, value: value}, acc ->
        parts = String.split(oid, ".")
        field_oid = Enum.at(parts, 10)
        # Always extract local_port from OID, then parse the field
        acc = extract_local_port(acc, oid)
        parse_lldp_field(field_oid, value, oid, acc)
      end)

    # Look up management address from separate table
    mgmt_addr = Map.get(mgmt_addresses, neighbor_key)
    neighbor_map = if mgmt_addr, do: Map.put(neighbor_map, :remote_address, mgmt_addr), else: neighbor_map

    build_neighbor_record("lldp", neighbor_map, interfaces, neighbor_map[:local_port])
  end

  # LLDP Remote Table field mappings (LLDP-MIB)
  defp parse_lldp_field("5", value, _oid, acc), do: Map.put(acc, :remote_chassis_id, format_chassis_id(value))

  defp parse_lldp_field("7", value, _oid, acc), do: Map.put(acc, :remote_port_id, format_port_id(value))

  defp parse_lldp_field("8", value, _oid, acc), do: Map.put(acc, :remote_port_description, to_string_safe(value))

  defp parse_lldp_field("9", value, _oid, acc), do: Map.put(acc, :remote_system_name, to_string_safe(value))

  defp parse_lldp_field("10", value, _oid, acc), do: Map.put(acc, :remote_system_description, to_string_safe(value))

  defp parse_lldp_field("12", value, _oid, acc), do: Map.put(acc, :remote_capabilities, parse_lldp_capabilities(value))

  defp parse_lldp_field(_field_oid, _value, _oid, acc), do: acc

  defp extract_local_port(acc, oid) do
    parts = String.split(oid, ".")
    [_rem_index, local_port | _] = Enum.reverse(parts)
    Map.put(acc, :local_port, String.to_integer(local_port))
  end

  defp build_neighbor_record(_protocol, _neighbor_map, _interfaces, nil), do: nil

  defp build_neighbor_record(protocol, neighbor_map, interfaces, if_index) do
    case find_interface_by_index(if_index, interfaces) do
      nil ->
        nil

      interface ->
        %{
          protocol: protocol,
          interface_id: interface.id,
          device_id: interface.device_id,
          remote_chassis_id: Sanitizer.sanitize_string(neighbor_map[:remote_chassis_id]),
          remote_system_name: Sanitizer.sanitize_string(neighbor_map[:remote_system_name]),
          remote_system_description: Sanitizer.sanitize_string(neighbor_map[:remote_system_description]),
          remote_platform: Sanitizer.sanitize_string(neighbor_map[:remote_platform]),
          remote_port_id: Sanitizer.sanitize_string(neighbor_map[:remote_port_id]),
          remote_port_description: Sanitizer.sanitize_string(neighbor_map[:remote_port_description]),
          remote_address: Sanitizer.sanitize_string(neighbor_map[:remote_address]),
          remote_capabilities: neighbor_map[:remote_capabilities] || [],
          last_discovered_at: DateTime.truncate(DateTime.utc_now(), :second)
        }
    end
  end

  # CDP Discovery (Cisco proprietary)

  @cdp_cache_table_oid "1.3.6.1.4.1.9.9.23.1.2.1.1"

  defp discover_cdp_neighbors(client_opts, interfaces) do
    case Client.walk(client_opts, @cdp_cache_table_oid) do
      {:ok, entries} when entries != [] ->
        parse_cdp_neighbors(entries, interfaces)

      {:ok, entries} when entries == %{} ->
        Logger.debug("No CDP neighbors found")
        []

      {:error, reason} ->
        Logger.debug("CDP discovery failed: #{inspect(reason)}")
        []
    end
  end

  defp parse_cdp_neighbors(entries, interfaces) do
    # Convert map from Client.walk to list of maps
    entry_list = Enum.map(entries, fn {oid, value} -> %{oid: oid, value: value} end)

    # Group by interface index and device index
    # OIDs are in format: 1.3.6.1.4.1.9.9.23.1.2.1.1.X.if_index.device_index
    grouped =
      Enum.group_by(entry_list, fn %{oid: oid} ->
        parts = String.split(oid, ".")
        # Get last two parts (interface index and device index)
        [device_idx | rest] = Enum.reverse(parts)
        [if_idx | _] = rest
        "#{if_idx}.#{device_idx}"
      end)

    grouped
    |> Enum.map(fn {_key, neighbor_entries} ->
      build_cdp_neighbor(neighbor_entries, interfaces)
    end)
    |> Enum.reject(&is_nil/1)
  end

  defp build_cdp_neighbor(entries, interfaces) do
    neighbor_map =
      Enum.reduce(entries, %{}, fn %{oid: oid, value: value}, acc ->
        parts = String.split(oid, ".")
        field_oid = Enum.at(parts, 13)
        # Always extract local_if_index from OID, then parse the field
        acc = extract_cdp_local_interface(acc, oid)
        parse_cdp_field(field_oid, value, oid, acc)
      end)

    build_neighbor_record("cdp", neighbor_map, interfaces, neighbor_map[:local_if_index])
  end

  defp parse_cdp_field("4", value, _oid, acc), do: Map.put(acc, :remote_address, format_ip_address(value))

  defp parse_cdp_field("6", value, _oid, acc), do: Map.put(acc, :remote_system_name, to_string_safe(value))

  defp parse_cdp_field("7", value, _oid, acc), do: Map.put(acc, :remote_port_id, to_string_safe(value))

  defp parse_cdp_field("8", value, _oid, acc), do: Map.put(acc, :remote_platform, to_string_safe(value))

  defp parse_cdp_field("9", value, _oid, acc), do: Map.put(acc, :remote_capabilities, parse_cdp_capabilities(value))

  defp parse_cdp_field(_field_oid, _value, _oid, acc), do: acc

  defp extract_cdp_local_interface(acc, oid) do
    parts = String.split(oid, ".")
    [_device_idx, if_idx | _] = Enum.reverse(parts)
    Map.put(acc, :local_if_index, String.to_integer(if_idx))
  end

  # Helper Functions

  defp find_interface_by_index(nil, _interfaces), do: nil

  defp find_interface_by_index(if_index, interfaces) do
    Enum.find(interfaces, fn interface ->
      interface.if_index == if_index
    end)
  end

  defp format_chassis_id(value) when is_binary(value) do
    # LLDP Chassis ID has subtype byte + value
    # If it's a printable string (subtype 4 = MAC as text, 7 = local), use it directly
    if String.valid?(value) and String.printable?(value) do
      String.trim(value)
    else
      # Otherwise convert binary to hex MAC format
      value
      |> :binary.bin_to_list()
      |> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
      |> String.downcase()
    end
  end

  defp format_chassis_id(value), do: to_string_safe(value)

  defp format_port_id(value) when is_binary(value) do
    # Try to convert to string first, if it fails use MAC format
    if String.valid?(value) and String.printable?(value) do
      String.trim(value)
    else
      format_chassis_id(value)
    end
  end

  defp format_port_id(value), do: to_string_safe(value)

  defp format_ip_address(value) when is_binary(value) and byte_size(value) == 4 do
    value
    |> :binary.bin_to_list()
    |> Enum.join(".")
  end

  defp format_ip_address(value), do: to_string_safe(value)

  defp to_string_safe(value) when is_binary(value) do
    if String.valid?(value) and String.printable?(value) do
      String.trim(value)
    else
      # Convert non-printable binary to hex string
      format_chassis_id(value)
    end
  end

  defp to_string_safe(value) when is_integer(value), do: Integer.to_string(value)
  defp to_string_safe(value), do: inspect(value)

  # LLDP capabilities bitmap (from LLDP-MIB)
  @lldp_cap_other 0x01
  @lldp_cap_repeater 0x02
  @lldp_cap_bridge 0x04
  @lldp_cap_wlan_ap 0x08
  @lldp_cap_router 0x10
  @lldp_cap_telephone 0x20
  @lldp_cap_docsis 0x40
  @lldp_cap_station 0x80

  defp parse_lldp_capabilities(<<caps::integer-size(16)>>) do
    []
    |> maybe_add_cap(caps, @lldp_cap_other, "other")
    |> maybe_add_cap(caps, @lldp_cap_repeater, "repeater")
    |> maybe_add_cap(caps, @lldp_cap_bridge, "bridge")
    |> maybe_add_cap(caps, @lldp_cap_wlan_ap, "wlan-ap")
    |> maybe_add_cap(caps, @lldp_cap_router, "router")
    |> maybe_add_cap(caps, @lldp_cap_telephone, "telephone")
    |> maybe_add_cap(caps, @lldp_cap_docsis, "docsis")
    |> maybe_add_cap(caps, @lldp_cap_station, "station")
  end

  defp parse_lldp_capabilities(_value), do: []

  # CDP capabilities bitmap (from CISCO-CDP-MIB)
  @cdp_cap_router 0x01
  @cdp_cap_trans_bridge 0x02
  @cdp_cap_source_bridge 0x04
  @cdp_cap_switch 0x08
  @cdp_cap_host 0x10
  @cdp_cap_igmp 0x20
  @cdp_cap_repeater 0x40

  defp parse_cdp_capabilities(value) when is_binary(value) and byte_size(value) == 1 do
    <<caps::integer-size(8)>> = value

    []
    |> maybe_add_cap(caps, @cdp_cap_router, "router")
    |> maybe_add_cap(caps, @cdp_cap_trans_bridge, "trans-bridge")
    |> maybe_add_cap(caps, @cdp_cap_source_bridge, "source-bridge")
    |> maybe_add_cap(caps, @cdp_cap_switch, "switch")
    |> maybe_add_cap(caps, @cdp_cap_host, "host")
    |> maybe_add_cap(caps, @cdp_cap_igmp, "igmp")
    |> maybe_add_cap(caps, @cdp_cap_repeater, "repeater")
  end

  defp parse_cdp_capabilities(_value), do: []

  defp maybe_add_cap(list, caps, bit_mask, name) do
    if Bitwise.band(caps, bit_mask) == 0 do
      list
    else
      [name | list]
    end
  end
end
