defmodule Towerops.Snmp.Discovery do
  @moduledoc """
  Core SNMP discovery orchestrator.

  Handles the complete discovery workflow:
  1. Test SNMP connectivity
  2. Discover system information
  3. Select appropriate device profile (Cisco, NetSNMP, or Base)
  4. Discover interfaces and sensors using profile
  5. Save discovered data to database

  Can be run manually or scheduled as a background job.
  """

  import Ecto.Query

  alias Towerops.Devices
  alias Towerops.Devices.Device, as: DeviceSchema
  alias Towerops.Devices.Firmware
  alias Towerops.Profiles.YamlProfiles
  alias Towerops.Repo
  alias Towerops.Snmp.Adapters.Replay
  alias Towerops.Snmp.ArpDiscovery
  alias Towerops.Snmp.Client
  alias Towerops.Snmp.DeferredDiscovery
  alias Towerops.Snmp.Device
  alias Towerops.Snmp.Interface
  alias Towerops.Snmp.IpAddress
  alias Towerops.Snmp.NeighborDiscovery
  alias Towerops.Snmp.Processor
  alias Towerops.Snmp.Profiles.Base
  alias Towerops.Snmp.Profiles.Dynamic
  alias Towerops.Snmp.Sensor
  alias Towerops.Snmp.Storage
  alias Towerops.Snmp.Vlan
  alias Towerops.Workers.DiscoveryWorker

  require Logger

  @type system_info :: %{
          optional(:sys_descr) => String.t(),
          optional(:sys_object_id) => String.t(),
          optional(:sys_name) => String.t(),
          optional(:sys_uptime) => non_neg_integer(),
          optional(:sys_contact) => String.t(),
          optional(:sys_location) => String.t(),
          optional(atom()) => term()
        }

  @type device_info :: %{
          optional(:manufacturer) => String.t(),
          optional(:model) => String.t(),
          optional(:firmware_version) => String.t() | nil,
          optional(:serial_number) => String.t() | nil,
          optional(atom()) => term()
        }

  @type interface_data :: %{
          required(:if_index) => non_neg_integer(),
          required(:if_descr) => String.t(),
          optional(:if_name) => String.t() | nil,
          optional(:if_alias) => String.t() | nil,
          optional(:if_type) => non_neg_integer() | nil,
          optional(:if_mtu) => non_neg_integer() | nil,
          optional(:if_speed) => non_neg_integer() | nil,
          optional(:if_phys_address) => String.t() | nil,
          optional(:if_admin_status) => non_neg_integer() | nil,
          optional(:if_oper_status) => non_neg_integer() | nil,
          optional(atom()) => term()
        }

  @type sensor_data :: %{
          required(:sensor_type) => String.t(),
          required(:sensor_index) => String.t(),
          required(:sensor_oid) => String.t(),
          required(:sensor_descr) => String.t(),
          required(:sensor_unit) => String.t(),
          required(:sensor_divisor) => number(),
          optional(:last_value) => float() | nil,
          optional(:status) => String.t(),
          optional(atom()) => term()
        }

  @type profile :: module() | {:yaml, map()}

  @type discovery_summary :: %{
          enqueued: non_neg_integer(),
          failed: non_neg_integer(),
          errors: [term()]
        }

  @doc """
  Runs discovery for a single device.
  Returns {:ok, device} or {:error, reason}.

  ## Examples

      iex> discover_device(device)
      {:ok, %Device{manufacturer: "Cisco", model: "C2960"}}

      iex> discover_device(device_without_snmp)
      {:error, :snmp_not_enabled}
  """
  @spec discover_device(DeviceSchema.t()) :: {:ok, Device.t()} | {:error, term()}
  def discover_device(%DeviceSchema{} = device) do
    if device.snmp_enabled do
      Logger.info(
        "Starting SNMP discovery: #{device.name} (#{device.ip_address})",
        device_id: device.id,
        device_name: device.name,
        device_ip: device.ip_address
      )

      client_opts = build_client_opts(device)

      # Categorize device speed for adaptive timeouts
      Logger.info("Categorizing device speed...", device_id: device.id)
      device_speed = DeferredDiscovery.categorize_device_speed(client_opts)
      timeouts = DeferredDiscovery.timeouts_for_speed(device_speed)

      if device_speed == :unresponsive do
        Logger.warning("Device #{device.name} is unresponsive, aborting discovery")
        {:error, :device_unresponsive}
      else
        Logger.info(
          "Device categorized as #{device_speed}, proceeding with discovery",
          device_id: device.id,
          device_speed: device_speed
        )

        do_discover_device(device, client_opts, timeouts)
      end
    else
      {:error, :snmp_not_enabled}
    end
  end

  @doc """
  Discovers a device using pre-built client options.

  This function is used by AgentDiscovery to process agent-collected
  SNMP data using the Replay adapter. It accepts client_opts that
  already contain the adapter and OID map.

  ## Parameters
  - device: The Device schema struct to discover
  - client_opts: Pre-built connection options including adapter

  ## Returns
  - `{:ok, discovered_device}` on success
  - `{:error, reason}` on failure
  """
  @spec discover_device_with_opts(DeviceSchema.t(), keyword()) ::
          {:ok, Device.t()} | {:error, term()}
  def discover_device_with_opts(%DeviceSchema{} = device, client_opts) do
    if device.snmp_enabled do
      Logger.debug("Starting discovery with custom opts for: #{device.name}")

      # Skip speed categorization for Replay adapter (agent-based discovery)
      # Agent data is pre-collected, so network checks are not needed
      adapter = Keyword.get(client_opts, :adapter)

      {device_speed, timeouts} =
        if adapter == Replay do
          Logger.debug("Using Replay adapter, skipping speed categorization")
          {:fast, DeferredDiscovery.timeouts_for_speed(:fast)}
        else
          # Categorize device speed for adaptive timeouts (real SNMP)
          speed = DeferredDiscovery.categorize_device_speed(client_opts)
          {speed, DeferredDiscovery.timeouts_for_speed(speed)}
        end

      if device_speed == :unresponsive do
        Logger.warning("Device #{device.name} is unresponsive, aborting discovery")
        {:error, :device_unresponsive}
      else
        do_discover_device(device, client_opts, timeouts)
      end
    else
      {:error, :snmp_not_enabled}
    end
  end

  # Internal discovery with adaptive timeouts based on device speed
  defp do_discover_device(device, client_opts, timeouts) do
    adapter = Keyword.get(client_opts, :adapter)

    # Skip connection test for Replay adapter (agent-provided data)
    connection_result =
      if adapter == Replay do
        Logger.debug("Using Replay adapter, skipping connection test", device_id: device.id)
        {:ok, :replay}
      else
        Logger.info("Testing SNMP connection...", device_id: device.id)
        Client.test_connection(client_opts)
      end

    # Critical path: connection, system info, device info must succeed
    with {:ok, _} <- connection_result,
         Logger.info("Discovering system info...", device_id: device.id),
         {:ok, system_info} <- discover_system(client_opts),
         Logger.info("Updating device name...", device_id: device.id),
         {:ok, device} <- update_device_name_from_snmp(device, system_info),
         Logger.info("Selecting device profile...", device_id: device.id),
         profile = select_profile(system_info, client_opts),
         Logger.info("Profile selected, building device info...", device_id: device.id),
         {:ok, device_info} <- build_device_info(client_opts, system_info, profile) do
      # Interface and sensor discovery: fall back to empty on timeout with warning
      # CRITICAL: Empty list on timeout means sync will NOT delete existing data
      # because save_discovery_results skips sync when list is empty and device already exists
      Logger.info("Discovering interfaces...", device_id: device.id)

      interfaces =
        case discover_interfaces_with_timeout(client_opts, profile, timeouts) do
          {:ok, ifaces} ->
            ifaces

          {:error, :interface_discovery_timeout} ->
            Logger.warning("Interface discovery timed out, using empty list", device_id: device.id)
            []
        end

      Logger.info("Discovering sensors...", device_id: device.id)

      sensors =
        case discover_sensors_with_timeout(client_opts, profile, timeouts) do
          {:ok, s} ->
            s

          {:error, :sensor_discovery_timeout} ->
            Logger.warning("Sensor discovery timed out, using empty list", device_id: device.id)
            []
        end

      # Secondary discovery: VLANs, IPs, processors, storage (already fall back to [] on timeout)
      Logger.info("Discovering VLANs...", device_id: device.id)
      {:ok, vlans} = discover_vlans_with_timeout(client_opts, profile, timeouts)

      Logger.info("Discovering IP addresses...", device_id: device.id)
      {:ok, ip_addresses} = discover_ip_addresses_with_timeout(client_opts, timeouts)
      _ = log_ip_discovery_results(device, ip_addresses)

      Logger.info("Discovering processors...", device_id: device.id)
      {:ok, processors} = discover_processors_with_timeout(client_opts, timeouts)

      Logger.info("Discovering storage...", device_id: device.id)
      {:ok, storage} = discover_storage_with_timeout(client_opts, timeouts)

      Logger.info("Saving discovery results (including IP addresses and processors)...", device_id: device.id)

      case save_discovery_results(device, device_info, interfaces, sensors, vlans, ip_addresses, processors) do
        {:ok, discovered_device} ->
          # Post-save discovery: neighbors, ARP (best-effort)
          Logger.info("Syncing storage...", device_id: device.id)
          _ = sync_storage(discovered_device, storage)

          Logger.info("Discovering neighbors...", device_id: device.id)
          {:ok, neighbors} = discover_neighbors_with_timeout(client_opts, discovered_device.interfaces, timeouts)

          Logger.info("Saving neighbors...", device_id: device.id)
          _ = save_neighbors(discovered_device.device_id, neighbors)

          Logger.info("Discovering ARP entries...", device_id: device.id)
          {:ok, arp_entries} = discover_arp_with_timeout(client_opts, timeouts)
          _ = save_arp_entries(discovered_device.device_id, arp_entries, discovered_device.interfaces)

          _ = update_device_discovery_time(device)
          Logger.debug("SNMP discovery completed successfully for: #{device.name}")

          is_rediscovery = device.last_discovery_at != nil
          _ = log_discovery_event(device, discovered_device, is_rediscovery)

          # Create monitoring checks for discovered entities
          Logger.info("Creating monitoring checks from discovered entities...", device_id: device.id)

          {:ok, check_counts} = Towerops.Snmp.create_checks_from_discovery(device, discovered_device)
          log_check_creation_results(device.id, check_counts)

          _ =
            Phoenix.PubSub.broadcast(
              Towerops.PubSub,
              "device:#{device.id}",
              {:discovery_completed, device.id}
            )

          {:ok, discovered_device}

        {:error, reason} ->
          Logger.error("Failed to save discovery results for #{device.name}: #{inspect(reason)}")
          {:error, reason}
      end
    else
      {:error, reason} = error ->
        Logger.error("SNMP discovery failed for #{device.name}: #{inspect(reason)}")
        error
    end
  end

  # Interface discovery with timeout
  # CRITICAL: Returns error on timeout instead of empty list to prevent data loss
  # Empty list would cause sync_interfaces to delete all existing interfaces
  defp discover_interfaces_with_timeout(client_opts, profile, timeouts) do
    DeferredDiscovery.slow_check(
      client_opts,
      fn -> discover_interfaces(client_opts, profile) end,
      timeout: timeouts[:slow],
      default: {:error, :interface_discovery_timeout}
    )
  end

  # Sensor discovery with timeout
  # CRITICAL: Returns error on timeout instead of empty list to prevent data loss
  # Empty list would cause sync_sensors to delete all existing sensors
  defp discover_sensors_with_timeout(client_opts, profile, timeouts) do
    DeferredDiscovery.slow_check(
      client_opts,
      fn -> discover_sensors(client_opts, profile) end,
      timeout: timeouts[:slow],
      default: {:error, :sensor_discovery_timeout}
    )
  end

  # Neighbor discovery with timeout - falls back to empty list on timeout
  defp discover_neighbors_with_timeout(client_opts, interfaces, timeouts) do
    DeferredDiscovery.slow_check(
      client_opts,
      fn -> NeighborDiscovery.discover_neighbors(client_opts, interfaces) end,
      timeout: timeouts[:deferred],
      default: []
    )
  end

  # ARP table discovery with timeout - falls back to empty list on timeout
  defp discover_arp_with_timeout(client_opts, timeouts) do
    DeferredDiscovery.slow_check(
      client_opts,
      fn -> ArpDiscovery.discover_arp_table(client_opts) end,
      timeout: timeouts[:deferred],
      default: []
    )
  end

  # VLAN discovery with timeout - falls back to empty list on timeout
  defp discover_vlans_with_timeout(client_opts, profile, timeouts) do
    DeferredDiscovery.slow_check(
      client_opts,
      fn -> discover_vlans(client_opts, profile) end,
      timeout: timeouts[:slow],
      default: []
    )
  end

  # IP address discovery with timeout - falls back to empty list on timeout
  defp discover_ip_addresses_with_timeout(client_opts, timeouts) do
    DeferredDiscovery.slow_check(
      client_opts,
      fn -> Base.discover_all_ip_addresses(client_opts) end,
      timeout: timeouts[:slow],
      default: []
    )
  end

  # Processor discovery with timeout - falls back to empty list on timeout
  defp discover_processors_with_timeout(client_opts, timeouts) do
    DeferredDiscovery.slow_check(
      client_opts,
      fn -> Base.discover_processors(client_opts) end,
      timeout: timeouts[:slow],
      default: []
    )
  end

  # Storage discovery with timeout - falls back to empty list on timeout
  defp discover_storage_with_timeout(client_opts, timeouts) do
    DeferredDiscovery.slow_check(
      client_opts,
      fn -> Base.discover_storage(client_opts) end,
      timeout: timeouts[:slow],
      default: []
    )
  end

  @doc """
  Enqueues Oban discovery jobs for all SNMP-enabled devices in an organization.

  Returns immediately after enqueuing jobs. Job status can be monitored via Oban dashboard.

  ## Examples

      iex> discover_all(org_id)
      {:ok, %{enqueued: 10, failed: 0}}
  """
  @spec discover_all(String.t()) :: {:ok, discovery_summary()}
  def discover_all(org_id) do
    device_list =
      DeviceSchema
      |> join(:inner, [e], s in assoc(e, :site))
      |> where([e, s], s.organization_id == ^org_id and e.snmp_enabled == true)
      |> Repo.all()

    Logger.debug("Enqueuing SNMP discovery for #{length(device_list)} devices in org #{org_id}")

    # Enqueue Oban jobs for each device
    results =
      Enum.reduce(device_list, %{enqueued: 0, failed: 0, errors: []}, fn device, acc ->
        case DiscoveryWorker.enqueue(device.id) do
          :ok ->
            %{acc | enqueued: acc.enqueued + 1}

          {:error, reason} ->
            Logger.error("Failed to enqueue discovery for device #{device.id}: #{inspect(reason)}")
            %{acc | failed: acc.failed + 1, errors: [reason | acc.errors]}
        end
      end)

    Logger.debug("Discovery jobs enqueued: #{results.enqueued} succeeded, #{results.failed} failed")

    {:ok, results}
  end

  @doc """
  Creates checks for all discovered SNMP entities (sensors, interfaces, processors, storage).

  This function is called after SNMP discovery completes to create check records
  that enable monitoring of the discovered entities.

  ## Parameters
  - device: The Device schema struct
  - snmp_device: The SnmpDevice with preloaded sensors, interfaces, processors, storage

  ## Returns
  A map with counts of created checks and any errors:
  ```
  %{
    sensors: 10,
    interfaces: 5,
    processors: 2,
    storage: 3,
    errors: []
  }
  ```
  """
  def create_checks_from_discovery(device, snmp_device) do
    alias Towerops.Monitoring

    # Ensure associations are loaded
    snmp_device = Repo.preload(snmp_device, [:sensors, :interfaces, :processors, :storage])

    result = %{
      sensors: 0,
      interfaces: 0,
      processors: 0,
      storage: 0,
      errors: []
    }

    # Create checks for sensors
    result =
      Enum.reduce(snmp_device.sensors, result, fn sensor, acc ->
        case create_sensor_check(device, sensor) do
          {:ok, _check} -> %{acc | sensors: acc.sensors + 1}
          {:error, reason} -> %{acc | errors: [%{type: "sensor", id: sensor.id, message: inspect(reason)} | acc.errors]}
        end
      end)

    # Create checks for interfaces
    result =
      Enum.reduce(snmp_device.interfaces, result, fn interface, acc ->
        case create_interface_check(device, interface) do
          {:ok, _check} ->
            %{acc | interfaces: acc.interfaces + 1}

          {:error, reason} ->
            %{acc | errors: [%{type: "interface", id: interface.id, message: inspect(reason)} | acc.errors]}
        end
      end)

    # Create checks for processors
    result =
      Enum.reduce(snmp_device.processors, result, fn processor, acc ->
        case create_processor_check(device, processor) do
          {:ok, _check} ->
            %{acc | processors: acc.processors + 1}

          {:error, reason} ->
            %{acc | errors: [%{type: "processor", id: processor.id, message: inspect(reason)} | acc.errors]}
        end
      end)

    # Create checks for storage
    result =
      Enum.reduce(snmp_device.storage, result, fn storage, acc ->
        case create_storage_check(device, storage) do
          {:ok, _check} -> %{acc | storage: acc.storage + 1}
          {:error, reason} -> %{acc | errors: [%{type: "storage", id: storage.id, message: inspect(reason)} | acc.errors]}
        end
      end)

    # Log results
    log_check_creation_results(device.id, result)

    result
  end

  # Private functions

  defp create_sensor_check(device, sensor) do
    alias Towerops.Monitoring

    with {:ok, check} <-
           Monitoring.create_check(%{
             organization_id: device.organization_id,
             device_id: device.id,
             name: sensor.sensor_descr,
             check_type: "snmp_sensor",
             source_type: "auto_discovery",
             source_id: sensor.id,
             interval_seconds: 60,
             enabled: true,
             config: %{
               "sensor_type" => sensor.sensor_type,
               "sensor_class" => sensor.sensor_class,
               "sensor_oid" => sensor.sensor_oid,
               "sensor_divisor" => sensor.sensor_divisor,
               "sensor_unit" => sensor.sensor_unit
             }
           }),
         {:ok, _job} <- Monitoring.schedule_check(check) do
      {:ok, check}
    end
  end

  defp create_interface_check(device, interface) do
    alias Towerops.Monitoring

    with {:ok, check} <-
           Monitoring.create_check(%{
             organization_id: device.organization_id,
             device_id: device.id,
             name: "Interface #{interface.if_descr}",
             check_type: "snmp_interface",
             source_type: "auto_discovery",
             source_id: interface.id,
             interval_seconds: 60,
             enabled: true,
             config: %{
               "if_index" => interface.if_index,
               "if_descr" => interface.if_descr
             }
           }),
         {:ok, _job} <- Monitoring.schedule_check(check) do
      {:ok, check}
    end
  end

  defp create_processor_check(device, processor) do
    alias Towerops.Monitoring

    with {:ok, check} <-
           Monitoring.create_check(%{
             organization_id: device.organization_id,
             device_id: device.id,
             name: "CPU #{processor.processor_index}",
             check_type: "snmp_processor",
             source_type: "auto_discovery",
             source_id: processor.id,
             interval_seconds: 60,
             enabled: true,
             config: %{
               "processor_index" => processor.processor_index,
               "processor_descr" => processor.processor_descr
             }
           }),
         {:ok, _job} <- Monitoring.schedule_check(check) do
      {:ok, check}
    end
  end

  defp create_storage_check(device, storage) do
    alias Towerops.Monitoring

    with {:ok, check} <-
           Monitoring.create_check(%{
             organization_id: device.organization_id,
             device_id: device.id,
             name: storage.description || storage.device_name || "Storage #{storage.storage_index}",
             check_type: "snmp_storage",
             source_type: "auto_discovery",
             source_id: storage.id,
             interval_seconds: 60,
             enabled: true,
             config: %{
               "storage_index" => storage.storage_index,
               "storage_descr" => storage.description
             }
           }),
         {:ok, _job} <- Monitoring.schedule_check(check) do
      {:ok, check}
    end
  end

  defp log_check_creation_results(device_id, check_counts) do
    total_checks =
      check_counts.sensors +
        check_counts.interfaces +
        check_counts.processors +
        check_counts.storage

    Logger.info(
      "Created #{total_checks} checks for device #{device_id}: " <>
        "#{check_counts.sensors} sensors, #{check_counts.interfaces} interfaces, " <>
        "#{check_counts.processors} processors, #{check_counts.storage} storage",
      device_id: device_id
    )

    if check_counts.errors != [] do
      Logger.warning(
        "Failed to create #{length(check_counts.errors)} checks for device #{device_id}",
        device_id: device_id,
        errors: check_counts.errors
      )
    end
  end

  @spec build_client_opts(DeviceSchema.t()) :: Client.connection_opts()
  defp build_client_opts(device) do
    # Get SNMP config with hierarchical fallback (device -> site -> organization)
    snmp_config = Devices.get_snmp_config(device)

    base_opts = [
      ip: device.ip_address,
      version: snmp_config.version,
      port: device.snmp_port || 161
    ]

    # Add version-specific credentials
    if snmp_config.version == "3" do
      v3_config = Devices.get_snmpv3_config(device)

      base_opts ++
        [
          security_name: v3_config.username,
          security_level: v3_config.security_level,
          auth_protocol: v3_config.auth_protocol,
          auth_password: v3_config.auth_password,
          priv_protocol: v3_config.priv_protocol,
          priv_password: v3_config.priv_password
        ]
    else
      base_opts ++ [community: snmp_config.community]
    end
  end

  @spec discover_system(Client.connection_opts()) :: {:ok, system_info()} | {:error, term()}
  defp discover_system(client_opts) do
    # Use Base profile for initial system discovery
    Base.discover_system_info(client_opts)
  end

  @doc """
  Selects the appropriate SNMP profile based on system information.

  First tries to match against database-stored device profiles (imported from LibreNMS).
  If no database match is found, falls back to the generic Base profile.

  ## Examples

      iex> select_profile(%{sys_descr: "Cisco IOS"}, client_opts)
      {:dynamic, %DeviceProfile{name: "ios"}}

      iex> select_profile(%{sys_object_id: "1.3.6.1.4.1.41112"}, client_opts)
      {:dynamic, %DeviceProfile{name: "airos"}}
  """
  @spec select_profile(system_info(), keyword()) :: profile()
  def select_profile(system_info, client_opts) do
    Logger.debug("Attempting to match profile for device",
      sys_object_id: Map.get(system_info, :sys_object_id),
      sys_descr: Map.get(system_info, :sys_descr)
    )

    case YamlProfiles.match_profile(system_info, client_opts) do
      %{name: name} = profile ->
        Logger.debug("Selected YAML profile: #{name}",
          profile_name: name,
          has_snmpget: has_snmpget_conditions?(profile)
        )

        {:yaml, profile}

      nil ->
        Logger.debug("No YAML profile matched, using Base profile")
        Base
    end
  end

  defp has_snmpget_conditions?(profile) do
    Enum.any?(profile.detection_blocks || [], fn block -> not is_nil(block[:snmpget]) end)
  end

  @spec build_device_info(Client.connection_opts(), system_info(), profile()) ::
          {:ok, device_info()}
  defp build_device_info(client_opts, system_info, {:yaml, profile}) do
    # Use YAML profile to identify device
    identified_info = Dynamic.identify_device(profile, client_opts, system_info)

    # Collect raw debug data using Base profile (works for any device)
    raw_data = Base.collect_raw_debug_data(client_opts)

    # Add vendor-specific debug data (wireless sensors, etc.)
    vendor_data = Dynamic.collect_vendor_debug_data(profile, client_opts)
    raw_data_with_vendor = Map.merge(raw_data, vendor_data)

    Logger.debug("Collected raw debug data for YAML profile: #{map_size(raw_data_with_vendor)} keys")

    identified_info_with_debug =
      identified_info
      |> Map.put(:_raw_discovery_data, raw_data_with_vendor)
      |> Map.put(:_last_discovery_at, DateTime.utc_now())

    {:ok, identified_info_with_debug}
  rescue
    error ->
      Logger.error("Failed to identify device with YAML profile: #{inspect(error)}")
      {:ok, system_info}
  end

  defp build_device_info(client_opts, system_info, profile) when is_atom(profile) do
    Logger.debug(
      "build_device_info: system_info has _raw_discovery_data: #{inspect(Map.has_key?(system_info, :_raw_discovery_data))}"
    )

    # Get profile-specific system info if profile has discover_system_info/1
    enriched_system_info =
      if function_exported?(profile, :discover_system_info, 1) do
        case profile.discover_system_info(client_opts) do
          {:ok, profile_info} ->
            Logger.debug("Enriched system_info with profile-specific data")
            Map.merge(system_info, profile_info)

          {:error, _reason} ->
            Logger.warning("Failed to get profile-specific system info, using base info")
            system_info
        end
      else
        system_info
      end

    # Let the hard-coded profile identify the device
    identified_info = profile.identify_device(enriched_system_info)

    # Collect raw debug data if profile supports it
    identified_info_with_debug =
      if function_exported?(profile, :collect_raw_debug_data, 1) do
        raw_data = profile.collect_raw_debug_data(client_opts)
        Logger.debug("Collected raw debug data: #{map_size(raw_data)} keys")

        identified_info
        |> Map.put(:_raw_discovery_data, raw_data)
        |> Map.put(:_last_discovery_at, DateTime.utc_now())
      else
        identified_info
      end

    Logger.debug(
      "build_device_info: identified_info has _raw_discovery_data: #{inspect(Map.has_key?(identified_info_with_debug, :_raw_discovery_data))}"
    )

    {:ok, identified_info_with_debug}
  rescue
    _error ->
      Logger.error("Failed to identify device with profile, falling back to Base profile")
      # Fall back to Base profile identification to ensure manufacturer/model are set
      {:ok, Base.identify_device(system_info)}
  end

  @spec discover_interfaces(Client.connection_opts(), profile()) ::
          {:ok, [interface_data()]}
  defp discover_interfaces(client_opts, {:yaml, profile}) do
    {:ok, interfaces} = Dynamic.discover_interfaces(profile, client_opts)
    Logger.debug("Discovered #{length(interfaces)} interfaces")
    {:ok, interfaces}
  end

  defp discover_interfaces(client_opts, profile) when is_atom(profile) do
    case profile.discover_interfaces(client_opts) do
      {:ok, interfaces} ->
        Logger.debug("Discovered #{length(interfaces)} interfaces")
        {:ok, interfaces}

      {:error, _} ->
        Logger.warning("Interface discovery failed, continuing without interfaces")
        {:ok, []}
    end
  end

  @spec discover_sensors(Client.connection_opts(), profile()) :: {:ok, [sensor_data()]}
  defp discover_sensors(client_opts, {:yaml, profile}) do
    {:ok, sensors} = Dynamic.discover_sensors(profile, client_opts)
    Logger.debug("Discovered #{length(sensors)} sensors")
    {:ok, sensors}
  end

  defp discover_sensors(client_opts, profile) when is_atom(profile) do
    {:ok, sensors} = profile.discover_sensors(client_opts)
    Logger.debug("Discovered #{length(sensors)} sensors")
    {:ok, sensors}
  end

  @spec discover_vlans(Client.connection_opts(), profile()) :: {:ok, [map()]}
  defp discover_vlans(client_opts, {:yaml, _profile}) do
    # Dynamic profiles use Base VLAN discovery
    {:ok, vlans} = Base.discover_vlans(client_opts)
    Logger.debug("Discovered #{length(vlans)} VLANs")
    {:ok, vlans}
  end

  defp discover_vlans(client_opts, profile) when is_atom(profile) do
    if function_exported?(profile, :discover_vlans, 1) do
      {:ok, vlans} = profile.discover_vlans(client_opts)
      Logger.debug("Discovered #{length(vlans)} VLANs")
      {:ok, vlans}
    else
      # Fall back to Base profile for VLAN discovery
      {:ok, vlans} = Base.discover_vlans(client_opts)
      Logger.debug("Discovered #{length(vlans)} VLANs using Base profile")
      {:ok, vlans}
    end
  end

  @spec save_discovery_results(
          DeviceSchema.t(),
          device_info(),
          [interface_data()],
          [sensor_data()],
          [map()],
          [map()],
          [map()]
        ) :: {:ok, Device.t()} | {:error, term()}
  defp save_discovery_results(device, device_info, interfaces, sensors, vlans, ip_addresses, processors) do
    # Sanitize raw_discovery_data BEFORE starting transaction to avoid DB timeout
    # This can take several seconds for large discovery data (thousands of OIDs)
    sanitized_device_info = prepare_device_info_for_save(device_info, device.id)

    start = System.monotonic_time(:millisecond)

    result =
      Repo.transaction(
        fn ->
          # Upsert Device
          snmp_device = upsert_device(device, sanitized_device_info)

          # Sync interfaces, sensors, and VLANs (preserving historical data)
          synced_interfaces = sync_interfaces(snmp_device, interfaces)
          _ = sync_sensors(snmp_device, sensors)
          _ = sync_vlans(snmp_device, vlans)

          # Sync IP addresses and processors inside transaction for atomicity
          device_with_interfaces = %{
            snmp_device
            | interfaces: Enum.map(synced_interfaces, &Map.put(&1, :device_id, snmp_device.device_id))
          }

          _ = sync_ip_addresses(device_with_interfaces, ip_addresses)
          _ = sync_processors(device_with_interfaces, processors)

          # Return device with interfaces loaded and device_id added
          # Use snmp_device.device_id which references the Equipment table
          device_with_interfaces
        end,
        timeout: 60_000
      )

    duration = System.monotonic_time(:millisecond) - start

    if duration > 5_000 do
      Logger.warning("Discovery transaction took #{duration}ms",
        device_id: device.id,
        interface_count: length(interfaces),
        sensor_count: length(sensors)
      )
    end

    result
  end

  # Prepares device_info for database save by sanitizing raw_discovery_data
  # This is done OUTSIDE the transaction to avoid DB timeout on large data
  defp prepare_device_info_for_save(device_info, device_id) do
    Logger.info("Preparing device_info for save...")

    # Extract and sanitize raw discovery data (this can take several seconds)
    raw_discovery_data =
      device_info
      |> Map.get(:_raw_discovery_data)
      |> sanitize_for_json()

    last_discovery_at = Map.get(device_info, :_last_discovery_at, DateTime.utc_now())

    Logger.info("Sanitized raw_discovery_data (#{map_size(raw_discovery_data || %{})} keys)")

    # Build device_attrs with sanitized data
    device_info
    |> Map.delete(:_raw_discovery_data)
    |> Map.delete(:_last_discovery_at)
    |> Map.put(:device_id, device_id)
    |> Map.put(:raw_discovery_data, raw_discovery_data)
    |> Map.put(:last_discovery_at, last_discovery_at)
  end

  @spec upsert_device(DeviceSchema.t(), map()) :: Device.t()
  defp upsert_device(device, device_attrs) do
    Logger.info("Upserting SNMP device for #{device.name}")

    case Repo.get_by(Device, device_id: device.id) do
      nil ->
        snmp_device =
          %Device{}
          |> Device.changeset(device_attrs)
          |> Repo.insert!()

        # Log initial firmware version (no old version)
        if snmp_device.firmware_version do
          Firmware.log_firmware_change(
            snmp_device.id,
            nil,
            snmp_device.firmware_version
          )
        end

        snmp_device

      existing_device ->
        old_version = existing_device.firmware_version
        new_version = device_attrs[:firmware_version]

        snmp_device =
          existing_device
          |> Device.changeset(device_attrs)
          |> Repo.update!()

        # Detect and log firmware version changes
        if version_changed?(old_version, new_version) do
          Firmware.log_firmware_change(
            snmp_device.id,
            old_version,
            new_version
          )
        end

        snmp_device
    end
  end

  defp version_changed?(nil, new_version) when is_binary(new_version), do: false
  defp version_changed?(old, new) when old == new, do: false
  defp version_changed?(old, new) when is_binary(old) and is_binary(new), do: true
  defp version_changed?(_, _), do: false

  @spec sync_interfaces(Device.t(), [interface_data()]) :: [Interface.t()]
  defp sync_interfaces(device, discovered_interfaces) do
    # Get existing interfaces for this device, indexed by if_index
    existing_interfaces =
      from(i in Interface, where: i.snmp_device_id == ^device.id)
      |> Repo.all()
      |> Map.new(&{&1.if_index, &1})

    # Get the set of discovered if_indices
    discovered_indices = MapSet.new(discovered_interfaces, & &1.if_index)

    # Delete interfaces that no longer exist on the device
    existing_indices = MapSet.new(Map.keys(existing_interfaces))
    removed_indices = MapSet.difference(existing_indices, discovered_indices)

    if MapSet.size(removed_indices) != 0 do
      removed_list = MapSet.to_list(removed_indices)

      Repo.delete_all(
        from i in Interface,
          where: i.snmp_device_id == ^device.id and i.if_index in ^removed_list
      )

      Logger.debug("Deleted #{MapSet.size(removed_indices)} removed interfaces")
    end

    # Upsert each discovered interface
    Enum.map(discovered_interfaces, fn interface_data ->
      case Map.get(existing_interfaces, interface_data.if_index) do
        nil ->
          # New interface - insert
          %Interface{}
          |> Interface.changeset(Map.put(interface_data, :snmp_device_id, device.id))
          |> Repo.insert!()

        existing ->
          # Existing interface - update (preserves ID and associated stats)
          existing
          |> Interface.changeset(interface_data)
          |> Repo.update!()
      end
    end)
  end

  @spec sync_sensors(Device.t(), [sensor_data()]) :: [Sensor.t()]
  defp sync_sensors(device, discovered_sensors) do
    # Deduplicate sensors by OID first - when multiple sensors share the same OID,
    # prefer the one with a proper description (not containing "MIB::")
    # This handles cases where YAML profiles and vendor profiles discover the same sensor
    discovered_sensors = deduplicate_sensors_by_oid(discovered_sensors)

    # Then deduplicate by sensor_index (keep first occurrence)
    # This prevents duplicate key errors when profiles generate duplicate indices
    discovered_sensors = Enum.uniq_by(discovered_sensors, & &1.sensor_index)

    # Get existing sensors for this device, indexed by sensor_index
    existing_sensors =
      from(s in Sensor, where: s.snmp_device_id == ^device.id)
      |> Repo.all()
      |> Map.new(&{&1.sensor_index, &1})

    # Get the set of discovered sensor_indices
    discovered_indices = MapSet.new(discovered_sensors, & &1.sensor_index)

    # Delete sensors that no longer exist on the device
    existing_indices = MapSet.new(Map.keys(existing_sensors))
    removed_indices = MapSet.difference(existing_indices, discovered_indices)

    if MapSet.size(removed_indices) != 0 do
      removed_list = MapSet.to_list(removed_indices)

      Repo.delete_all(
        from s in Sensor,
          where: s.snmp_device_id == ^device.id and s.sensor_index in ^removed_list
      )

      Logger.debug("Deleted #{MapSet.size(removed_indices)} removed sensors")
    end

    # Upsert each discovered sensor
    Enum.map(discovered_sensors, fn sensor_data ->
      case Map.get(existing_sensors, sensor_data.sensor_index) do
        nil ->
          # New sensor - insert
          %Sensor{}
          |> Sensor.changeset(Map.put(sensor_data, :snmp_device_id, device.id))
          |> Repo.insert!()

        existing ->
          # Existing sensor - update (preserves ID and associated readings)
          existing
          |> Sensor.changeset(sensor_data)
          |> Repo.update!()
      end
    end)
  end

  @spec sync_vlans(Device.t(), [map()]) :: [Vlan.t()]
  defp sync_vlans(device, discovered_vlans) do
    # Get existing VLANs for this device, indexed by vlan_id
    existing_vlans =
      from(v in Vlan, where: v.snmp_device_id == ^device.id)
      |> Repo.all()
      |> Map.new(&{&1.vlan_id, &1})

    # Get the set of discovered vlan_ids
    discovered_vlan_ids = MapSet.new(discovered_vlans, & &1.vlan_id)

    # Delete VLANs that no longer exist on the device
    existing_vlan_ids = MapSet.new(Map.keys(existing_vlans))
    removed_vlan_ids = MapSet.difference(existing_vlan_ids, discovered_vlan_ids)

    if MapSet.size(removed_vlan_ids) != 0 do
      removed_list = MapSet.to_list(removed_vlan_ids)

      Repo.delete_all(
        from v in Vlan,
          where: v.snmp_device_id == ^device.id and v.vlan_id in ^removed_list
      )

      Logger.debug("Deleted #{MapSet.size(removed_vlan_ids)} removed VLANs")
    end

    # Upsert each discovered VLAN
    Enum.map(discovered_vlans, fn vlan_data ->
      case Map.get(existing_vlans, vlan_data.vlan_id) do
        nil ->
          # New VLAN - insert
          %Vlan{}
          |> Vlan.changeset(Map.put(vlan_data, :snmp_device_id, device.id))
          |> Repo.insert!()

        existing ->
          # Existing VLAN - update
          existing
          |> Vlan.changeset(vlan_data)
          |> Repo.update!()
      end
    end)
  end

  @spec sync_ip_addresses(Device.t(), [map()]) :: :ok
  @doc """
  Sync IP addresses from discovery/polling results to database.
  """
  def sync_ip_addresses(device, discovered_ip_addresses) do
    # Build a map of if_index to interface ID from the device's interfaces
    if_index_to_interface =
      Map.new(device.interfaces, fn iface -> {iface.if_index, iface.id} end)

    # Get existing IP addresses for all interfaces of this device
    interface_ids = Map.values(if_index_to_interface)

    existing_ip_addresses =
      from(ip in IpAddress, where: ip.snmp_interface_id in ^interface_ids)
      |> Repo.all()
      |> Map.new(fn ip -> {{ip.snmp_interface_id, ip.ip_address}, ip} end)

    # Track which IP addresses we've seen
    discovered_keys =
      discovered_ip_addresses
      |> Enum.map(fn ip_data ->
        interface_id = Map.get(if_index_to_interface, ip_data.if_index)
        {interface_id, ip_data.ip_address}
      end)
      |> Enum.reject(fn {iface_id, _} -> is_nil(iface_id) end)
      |> MapSet.new()

    # Delete IP addresses that no longer exist
    existing_keys = MapSet.new(Map.keys(existing_ip_addresses))
    removed_keys = MapSet.difference(existing_keys, discovered_keys)

    if MapSet.size(removed_keys) > 0 do
      removed_list = MapSet.to_list(removed_keys)

      Enum.each(removed_list, fn {interface_id, ip_address} ->
        Repo.delete_all(
          from ip in IpAddress,
            where: ip.snmp_interface_id == ^interface_id and ip.ip_address == ^ip_address
        )
      end)

      Logger.debug("Deleted #{MapSet.size(removed_keys)} removed IP addresses")
    end

    # Upsert each discovered IP address
    Enum.each(discovered_ip_addresses, fn ip_data ->
      upsert_ip_address(ip_data, if_index_to_interface, existing_ip_addresses)
    end)

    if Application.get_env(:towerops, :env) == :dev do
      ipv4_count = Enum.count(discovered_ip_addresses, &(&1.ip_type == "ipv4"))
      ipv6_count = Enum.count(discovered_ip_addresses, &(&1.ip_type == "ipv6"))
      removed_count = MapSet.size(removed_keys)

      Logger.info(
        "Synced IP addresses: #{ipv4_count} IPv4, #{ipv6_count} IPv6, removed #{removed_count}",
        device_id: device.device_id
      )
    else
      Logger.debug("Synced #{length(discovered_ip_addresses)} IP addresses for device")
    end

    :ok
  end

  defp upsert_ip_address(ip_data, if_index_to_interface, _existing_ip_addresses) do
    interface_id = Map.get(if_index_to_interface, ip_data.if_index)

    if interface_id do
      ip_attrs = Map.put(ip_data, :snmp_interface_id, interface_id)

      # Use on_conflict to handle race conditions gracefully
      %IpAddress{}
      |> IpAddress.changeset(ip_attrs)
      |> Repo.insert(
        on_conflict: {:replace_all_except, [:id, :inserted_at]},
        conflict_target: [:snmp_interface_id, :ip_address]
      )
    end
  end

  @spec sync_processors(Device.t(), [map()]) :: :ok
  @doc """
  Sync processors from discovery/polling results to database.
  """
  def sync_processors(device, discovered_processors) do
    # Get existing processors for this device, indexed by processor_index
    existing_processors =
      from(p in Processor, where: p.snmp_device_id == ^device.id)
      |> Repo.all()
      |> Map.new(&{&1.processor_index, &1})

    # Get the set of discovered processor_indices
    discovered_indices = MapSet.new(discovered_processors, & &1.processor_index)

    # Delete processors that no longer exist on the device
    existing_indices = MapSet.new(Map.keys(existing_processors))
    removed_indices = MapSet.difference(existing_indices, discovered_indices)

    if MapSet.size(removed_indices) > 0 do
      removed_list = MapSet.to_list(removed_indices)

      Repo.delete_all(
        from p in Processor,
          where: p.snmp_device_id == ^device.id and p.processor_index in ^removed_list
      )

      Logger.debug("Deleted #{MapSet.size(removed_indices)} removed processors")
    end

    # Upsert each discovered processor
    Enum.each(discovered_processors, fn processor_data ->
      upsert_processor(device.id, processor_data, existing_processors)
    end)

    Logger.debug("Synced #{length(discovered_processors)} processors for device")
    :ok
  end

  # Helper to upsert a single processor (insert or update)
  defp upsert_processor(device_id, processor_data, existing_processors) do
    case Map.get(existing_processors, processor_data.processor_index) do
      nil ->
        # New processor - insert with conflict handling
        attrs = Map.put(processor_data, :snmp_device_id, device_id)

        %Processor{}
        |> Processor.changeset(attrs)
        |> Repo.insert(
          on_conflict: {:replace_all_except, [:id, :inserted_at]},
          conflict_target: [:snmp_device_id, :processor_index]
        )
        |> handle_processor_insert_result()

      existing ->
        # Existing processor - update
        existing
        |> Processor.changeset(processor_data)
        |> Repo.update!()
    end
  end

  defp handle_processor_insert_result({:ok, _}), do: :ok

  defp handle_processor_insert_result({:error, changeset}) do
    Logger.error("Failed to upsert processor: #{inspect(changeset)}")
  end

  @spec sync_storage(Device.t(), [map()]) :: :ok
  @doc """
  Sync storage from discovery/polling results to database.
  """
  def sync_storage(device, discovered_storage) do
    # Get existing storage for this device, indexed by storage_index
    existing_storage =
      from(s in Storage, where: s.snmp_device_id == ^device.id)
      |> Repo.all()
      |> Map.new(&{&1.storage_index, &1})

    # Get the set of discovered storage_indices (convert string index to integer)
    discovered_indices =
      MapSet.new(discovered_storage, fn s ->
        normalize_storage_index(s.storage_index)
      end)

    # Delete storage that no longer exists on the device
    existing_indices = MapSet.new(Map.keys(existing_storage))
    removed_indices = MapSet.difference(existing_indices, discovered_indices)

    if MapSet.size(removed_indices) > 0 do
      removed_list = MapSet.to_list(removed_indices)

      Repo.delete_all(
        from s in Storage,
          where: s.snmp_device_id == ^device.id and s.storage_index in ^removed_list
      )

      Logger.debug("Deleted #{MapSet.size(removed_indices)} removed storage entries")
    end

    # Upsert each discovered storage
    Enum.each(discovered_storage, fn storage_data ->
      storage_index = normalize_storage_index(storage_data.storage_index)
      storage_attrs = Map.put(storage_data, :storage_index, storage_index)

      case Map.get(existing_storage, storage_index) do
        nil ->
          # New storage - insert
          %Storage{}
          |> Storage.changeset(Map.put(storage_attrs, :snmp_device_id, device.id))
          |> Repo.insert!()

        existing ->
          # Existing storage - update
          existing
          |> Storage.changeset(storage_attrs)
          |> Repo.update!()
      end
    end)

    Logger.debug("Synced #{length(discovered_storage)} storage entries for device")
    :ok
  end

  # Normalize storage index to integer (Base.discover_storage may return string from OID parsing)
  defp normalize_storage_index(index) when is_integer(index), do: index
  defp normalize_storage_index(index) when is_binary(index), do: String.to_integer(index)

  @spec update_device_discovery_time(DeviceSchema.t()) ::
          {:ok, DeviceSchema.t()} | {:error, Ecto.Changeset.t()}
  defp update_device_discovery_time(device) do
    device
    |> Ecto.Changeset.change(last_discovery_at: DateTime.truncate(DateTime.utc_now(), :second))
    |> Repo.update()
  end

  @spec save_neighbors(binary(), [map()]) :: :ok
  @doc """
  Save discovered neighbors to database.
  """
  def save_neighbors(device_id, neighbors) do
    # Delete stale neighbors (not seen in last 5 minutes)
    cutoff = DateTime.add(DateTime.utc_now(), -5, :minute)
    Towerops.Snmp.delete_stale_neighbors(device_id, cutoff)

    # Upsert each discovered neighbor
    Enum.each(neighbors, fn neighbor_data ->
      Towerops.Snmp.upsert_neighbor(neighbor_data)
    end)

    Logger.debug("Saved #{length(neighbors)} neighbors for device #{device_id}")
    :ok
  end

  @spec save_arp_entries(binary(), [map()], [Interface.t()]) :: :ok
  @doc """
  Save discovered ARP entries to database.
  """
  def save_arp_entries(device_id, arp_entries, interfaces) do
    # Delete stale ARP entries (not seen in last 30 minutes)
    cutoff = DateTime.add(DateTime.utc_now(), -30, :minute)
    Towerops.Snmp.delete_stale_arp_entries(device_id, cutoff)

    # Upsert each discovered ARP entry
    {success, _errors} = Towerops.Snmp.upsert_arp_entries(device_id, arp_entries, interfaces)

    Logger.debug("Saved #{success} ARP entries for device #{device_id}")
    :ok
  end

  @spec update_device_name_from_snmp(DeviceSchema.t(), system_info()) ::
          {:ok, DeviceSchema.t()} | {:error, term()}
  defp update_device_name_from_snmp(device, system_info) do
    # Only update device name if it's currently empty and we got a sysName from SNMP
    name_empty? = is_nil(device.name) or device.name == ""

    if name_empty? and Map.has_key?(system_info, :sys_name) do
      sys_name = Map.get(system_info, :sys_name)

      if sys_name && sys_name != "" do
        Logger.info(
          "Updating device name from SNMP sysName: #{sys_name}",
          device_id: device.id
        )

        result =
          device
          |> Ecto.Changeset.change(name: sys_name)
          |> Repo.update()

        Logger.info("Device name update completed", device_id: device.id)
        result
      else
        Logger.info("sysName empty, keeping device name unchanged", device_id: device.id)
        {:ok, device}
      end
    else
      Logger.info("Device name already set or no sysName, skipping update", device_id: device.id)
      {:ok, device}
    end
  end

  defp log_ip_discovery_results(device, ip_addresses) do
    if Application.get_env(:towerops, :env) == :dev do
      ipv4_count = Enum.count(ip_addresses, &(&1.ip_type == "ipv4"))
      ipv6_count = Enum.count(ip_addresses, &(&1.ip_type == "ipv6"))

      Logger.info(
        "Discovered IP addresses: #{ipv4_count} IPv4, #{ipv6_count} IPv6 (total: #{length(ip_addresses)})",
        device_id: device.id
      )
    end

    :ok
  end

  defp log_discovery_event(device, discovered_device, is_rediscovery) do
    event_type = if is_rediscovery, do: "device_rediscovered", else: "device_discovered"

    message =
      if is_rediscovery do
        "Device rediscovered: #{device.name} (#{discovered_device.manufacturer} #{discovered_device.model})"
      else
        "Device discovered: #{device.name} (#{discovered_device.manufacturer} #{discovered_device.model})"
      end

    interface_count =
      case discovered_device.interfaces do
        %Ecto.Association.NotLoaded{} -> 0
        interfaces when is_list(interfaces) -> length(interfaces)
      end

    metadata = %{
      manufacturer: discovered_device.manufacturer,
      model: discovered_device.model,
      firmware_version: discovered_device.firmware_version,
      serial_number: discovered_device.serial_number,
      interface_count: interface_count,
      sensor_count:
        Repo.aggregate(
          from(s in Sensor, where: s.snmp_device_id == ^discovered_device.id),
          :count
        )
    }

    event_attrs = %{
      device_id: device.id,
      event_type: event_type,
      severity: "info",
      message: message,
      metadata: metadata,
      occurred_at: DateTime.utc_now()
    }

    # Publish event for EventLogger to persist
    Phoenix.PubSub.broadcast(
      Towerops.PubSub,
      "device:events",
      {:device_event, event_attrs}
    )
  end

  # Sanitizes data structures to ensure they are JSON-encodable
  # Converts non-UTF8 binary strings to base64 representation
  @spec sanitize_for_json(term()) :: term()
  defp sanitize_for_json(nil), do: nil

  # Handle DateTime and other structs before generic map clause
  defp sanitize_for_json(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
  defp sanitize_for_json(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
  defp sanitize_for_json(%Date{} = d), do: Date.to_iso8601(d)
  defp sanitize_for_json(%Time{} = t), do: Time.to_iso8601(t)

  # Generic struct handling - convert to map first, then sanitize
  defp sanitize_for_json(%{__struct__: _} = struct) do
    struct
    |> Map.from_struct()
    |> sanitize_for_json()
  end

  defp sanitize_for_json(map) when is_map(map) do
    Map.new(map, fn {k, v} -> {sanitize_for_json(k), sanitize_for_json(v)} end)
  end

  defp sanitize_for_json(list) when is_list(list) do
    Enum.map(list, &sanitize_for_json/1)
  end

  defp sanitize_for_json(binary) when is_binary(binary) do
    if String.printable?(binary) do
      binary
    else
      # Convert non-printable binary to base64 with marker
      # Use Elixir.Base to avoid conflict with Towerops.Snmp.Profiles.Base alias
      %{"_binary_base64" => Elixir.Base.encode64(binary)}
    end
  end

  defp sanitize_for_json(tuple) when is_tuple(tuple) do
    tuple
    |> Tuple.to_list()
    |> Enum.map(&sanitize_for_json/1)
  end

  defp sanitize_for_json(other), do: other

  # Deduplicate sensors by OID - when multiple sensors share the same OID,
  # prefer the one with a proper description (not containing "MIB::").
  # Normalizes OIDs by stripping leading dots so ".1.3.6.1..." and "1.3.6.1..."
  # are treated as the same OID (gosnmp returns leading dots, definitions don't).
  defp deduplicate_sensors_by_oid(sensors) do
    sensors
    |> Enum.group_by(&String.trim_leading(&1.sensor_oid, "."))
    |> Enum.map(fn {_oid, group} ->
      # Prefer sensor with proper description (no MIB name)
      Enum.find(group, fn s -> not String.contains?(s.sensor_descr, "MIB::") end) ||
        List.last(group)
    end)
  end
end
