defmodule Towerops.Snmp.Profiles.Dynamic do
  @moduledoc """
  Dynamic profile that uses YAML-loaded profiles with MIB name translation.
  Profiles are loaded from priv/profiles/ and cached in ETS.
  """

  alias Towerops.Snmp.Client
  alias Towerops.Snmp.Discovery
  alias Towerops.Snmp.Profiles.Vendors.Registry, as: VendorRegistry

  require Logger

  @doc """
  Identifies device using profile's device_oids.
  Translates MIB names to numeric OIDs and polls the device.
  """
  @spec identify_device(map(), Client.connection_opts(), Discovery.system_info()) ::
          Discovery.device_info()
  def identify_device(profile, client_opts, system_info) do
    device_oids = Map.get(profile, :device_oids, %{})

    device_data =
      device_oids
      |> Enum.map(fn {field, mib_name} -> fetch_device_oid_value(field, mib_name, client_opts) end)
      |> Enum.reject(&is_nil/1)
      |> Map.new()

    # Get hardware model: YAML hardware OID → sysDescr_regex → vendor-specific
    # → profile-based fallback → sysDescr
    model =
      Map.get(device_data, :hardware) ||
        extract_hardware_from_regex(profile, system_info[:sys_descr]) ||
        detect_vendor_hardware(profile, client_opts) ||
        profile_based_model_fallback(profile, system_info[:sys_descr]) ||
        system_info[:sys_descr]

    # Get firmware version: YAML version OID → vendor-specific → format from device_data
    firmware_version =
      Map.get(device_data, :firmware_version) ||
        detect_vendor_version(profile, client_opts) ||
        format_firmware_version(profile, device_data)

    # Get serial number: YAML serial_number OID → vendor-specific
    serial_number =
      Map.get(device_data, :serial_number) ||
        detect_vendor_serial_number(profile, client_opts)

    # Merge with system_info
    system_info
    |> Map.merge(%{
      manufacturer: profile.vendor,
      model: model,
      firmware_version: firmware_version,
      serial_number: serial_number,
      latitude: Map.get(device_data, :latitude),
      longitude: Map.get(device_data, :longitude)
    })
    |> Map.merge(device_data)
  end

  @doc """
  Discovers sensors using profile's sensor_oids and table-based sensors.
  Combines profile-specific sensors with standard ENTITY-SENSOR-MIB discovery.
  Works like LibreNMS - discovers all sensor types (scalar and table-based).
  """
  @spec discover_sensors(map(), Client.connection_opts()) ::
          {:ok, [Discovery.sensor_data()]}
  def discover_sensors(profile, client_opts) do
    alias Towerops.Snmp.Profiles.Base

    # Always discover ENTITY-SENSOR-MIB sensors (most devices support this)
    {:ok, base_sensors} = Base.discover_sensors(client_opts)

    # Scalar sensors from profile (direct OID polling)
    scalar_sensors = Map.get(profile, :sensor_oids, [])

    scalar_results =
      scalar_sensors
      |> Enum.map(&fetch_sensor_value(&1, client_opts))
      |> Enum.reject(&is_nil/1)

    # Table-based sensors from profile (OID walks for all sensor types)
    table_sensor_oids = Map.get(profile, :table_sensor_oids, [])
    processor_oids = Map.get(profile, :processor_oids, [])
    count_sensor_oids = Map.get(profile, :count_sensor_oids, [])
    state_sensor_oids = Map.get(profile, :state_sensor_oids, [])

    # Walk table sensors (temperature, voltage, power, etc. with {{ $index }})
    table_results = discover_table_sensors(table_sensor_oids, client_opts)
    processor_results = discover_table_sensors(processor_oids, client_opts)
    count_results = discover_table_sensors(count_sensor_oids, client_opts)
    state_results = discover_state_sensors(state_sensor_oids, client_opts)

    # Wireless sensors (vendor-specific OIDs like those in LibreNMS PHP files)
    wireless_results = discover_vendor_wireless_sensors(profile, client_opts)
    Logger.debug("Discovered #{length(wireless_results)} wireless sensors for profile #{profile.name}")

    # Combine all sensors - profile-specific sensors first, then base sensors
    all_sensors =
      scalar_results ++
        table_results ++
        processor_results ++
        count_results ++
        state_results ++ wireless_results ++ base_sensors

    # Deduplicate by OID - when multiple sensors have the same OID,
    # prefer the one with a proper description (not a MIB name)
    deduped_sensors = deduplicate_by_oid(all_sensors)

    # Apply vendor-specific post-processing (e.g., Arista DOM power conversion + thresholds)
    final_sensors = apply_vendor_post_processing(profile, deduped_sensors, client_opts)

    {:ok, final_sensors}
  end

  @doc """
  Uses Base profile for interface discovery.
  """
  @spec discover_interfaces(map(), Client.connection_opts()) ::
          {:ok, [Discovery.interface_data()]}
  def discover_interfaces(_profile, client_opts) do
    alias Towerops.Snmp.Profiles.Base

    Base.discover_interfaces(client_opts)
  end

  @doc """
  Discovers wireless sensors using vendor-specific OIDs.
  Mirrors LibreNMS PHP-based wireless sensor discovery (e.g., Airos.php, Epmp.php).
  Returns sensors for frequency, rssi, snr, clients, ccq, power, etc.
  """
  @spec discover_wireless_sensors(map(), Client.connection_opts()) ::
          {:ok, [Discovery.sensor_data()]}
  def discover_wireless_sensors(profile, client_opts) do
    sensors = discover_vendor_wireless_sensors(profile, client_opts)
    {:ok, sensors}
  end

  # Private helper functions

  # Vendor-specific hardware detection using vendor modules (like LibreNMS OS PHP files)
  defp detect_vendor_hardware(%{name: name}, client_opts) do
    VendorRegistry.detect_hardware(name, client_opts)
  end

  defp detect_vendor_hardware(_profile, _client_opts), do: nil

  # Vendor-specific version detection using vendor modules (like LibreNMS OS PHP files)
  defp detect_vendor_version(%{name: name}, client_opts) do
    case VendorRegistry.get_vendor(name) do
      nil ->
        nil

      vendor ->
        # Check if vendor module implements detect_version/1
        if function_exported?(vendor, :detect_version, 1) do
          vendor.detect_version(client_opts)
        end
    end
  end

  defp detect_vendor_version(_profile, _client_opts), do: nil

  # Vendor-specific serial number detection using vendor modules
  defp detect_vendor_serial_number(%{name: name}, client_opts) do
    case VendorRegistry.get_vendor(name) do
      nil ->
        nil

      vendor ->
        # Check if vendor module implements detect_serial_number/1
        if function_exported?(vendor, :detect_serial_number, 1) do
          vendor.detect_serial_number(client_opts)
        end
    end
  end

  defp detect_vendor_serial_number(_profile, _client_opts), do: nil

  # Extract hardware model from sysDescr using profile's sysDescr_regex
  # LibreNMS uses named capture groups like (?<hardware>...) in the regex
  defp extract_hardware_from_regex(%{hardware_regex: regex}, sys_descr)
       when is_struct(regex, Regex) and is_binary(sys_descr) do
    case Regex.named_captures(regex, sys_descr) do
      %{"hardware" => hardware} when hardware != "" -> hardware
      _ -> nil
    end
  end

  defp extract_hardware_from_regex(_profile, _sys_descr), do: nil

  # Profile-based model fallback for when vendor detection fails
  # Uses profile type/category as a generic model name
  defp profile_based_model_fallback(%{type: type, vendor: vendor}, _sys_descr)
       when not is_nil(type) and not is_nil(vendor) do
    # Use profile type (e.g., "wireless") as generic model
    "#{String.capitalize(type)} Device"
  end

  defp profile_based_model_fallback(_profile, _sys_descr), do: nil

  defp format_firmware_version(%{vendor: vendor}, device_data) when vendor in ["MikroTik", "Mikrotik RouterOS"] do
    case {Map.get(device_data, :firmware_version), Map.get(device_data, :license_version)} do
      {fw, license} when not is_nil(fw) and not is_nil(license) ->
        "MikroTik RouterOS #{fw} (Level #{license})"

      {fw, _} when not is_nil(fw) ->
        "MikroTik RouterOS #{fw}"

      _ ->
        nil
    end
  end

  defp format_firmware_version(%{vendor: vendor}, device_data) when vendor in ["Ubiquiti", "Ubiquiti AirOS"] do
    # Extract version from format like "XW.v8.7.21.48401.251212.0939"
    # Remove the ".v" prefix to get just "8.7.21.48401.251212.0939"
    case Map.get(device_data, :firmware_version) do
      nil ->
        nil

      version when is_binary(version) ->
        case Regex.run(~r/\.v(.*)$/, version) do
          [_, extracted_version] -> extracted_version
          _ -> version
        end

      version ->
        version
    end
  end

  defp format_firmware_version(_profile, device_data) do
    # For other devices, use raw firmware version
    Map.get(device_data, :firmware_version)
  end

  defp fetch_device_oid_value(field, mib_name, client_opts) do
    # Client.get already handles MIB resolution via ToweropsNative NIF
    case Client.get(client_opts, mib_name) do
      {:ok, value} ->
        {field, value}

      {:error, _} ->
        nil
    end
  end

  defp fetch_sensor_value(sensor_oid, client_opts) do
    mib_name = Map.get(sensor_oid, :mib_name)

    # Client.get already handles MIB resolution via ToweropsNative NIF
    case Client.get(client_opts, mib_name) do
      {:ok, value} when is_number(value) and value > 0 ->
        %{
          sensor_type: sensor_oid[:sensor_type],
          sensor_index: sensor_oid[:sensor_type],
          sensor_oid: mib_name,
          sensor_descr: sensor_oid[:sensor_descr] || sensor_oid[:sensor_type],
          sensor_unit: sensor_oid[:sensor_unit] || "",
          sensor_divisor: sensor_oid[:sensor_divisor] || 1,
          last_value: value / 1
        }

      _ ->
        nil
    end
  end

  # Discover table-based sensors by walking the base OID
  defp discover_table_sensors(sensor_defs, client_opts) do
    Enum.flat_map(sensor_defs, &walk_table_sensor(&1, client_opts))
  end

  defp walk_table_sensor(sensor_def, client_opts) do
    base_oid = sensor_def[:base_oid]
    descr_oid = sensor_def[:descr_oid]

    case Client.walk(client_opts, base_oid) do
      {:ok, results} when is_map(results) and map_size(results) > 0 ->
        # If there's a descr_oid, walk it to get actual sensor names
        descr_map = walk_descr_oid(descr_oid, client_opts)
        parse_table_walk_results(results, sensor_def, descr_map)

      _ ->
        []
    end
  end

  # Walk the description OID to get actual sensor names (e.g., mtxrGaugeName)
  defp walk_descr_oid(nil, _client_opts), do: %{}

  defp walk_descr_oid(descr_mib_name, client_opts) do
    # Client.walk already handles MIB resolution via ToweropsNative NIF
    case Client.walk(client_opts, descr_mib_name) do
      {:ok, results} when is_map(results) ->
        # Build a map of index -> description
        Map.new(results, fn {oid, value} ->
          # Extract the index from the full OID (last component)
          index = oid |> String.split(".") |> List.last()
          {index, to_string(value)}
        end)

      _ ->
        %{}
    end
  end

  defp parse_table_walk_results(results, sensor_def, descr_map) when is_map(results) do
    results
    |> Enum.with_index(1)
    |> Enum.map(fn {{oid, value}, idx} ->
      # Extract the index from the OID to look up the description
      oid_index = oid |> String.split(".") |> List.last()
      build_table_sensor(sensor_def, oid, value, idx, descr_map, oid_index)
    end)
    |> Enum.reject(&is_nil/1)
  end

  # Build a sensor from table walk result
  defp build_table_sensor(sensor_def, oid, value, idx, descr_map, oid_index) when is_number(value) do
    # Use index_template from YAML if available, otherwise generate default index
    sensor_index =
      case sensor_def[:index_template] do
        nil ->
          "#{sensor_def[:sensor_type]}_#{idx}"

        template ->
          # Replace {{ $index }} with the actual OID index
          # Coerce to string since YAML parses bare numbers (e.g. `index: 0`) as integers
          template |> to_string() |> String.replace("{{ $index }}", oid_index)
      end

    %{
      sensor_type: sensor_def[:sensor_type],
      sensor_index: sensor_index,
      sensor_oid: oid,
      sensor_descr: build_sensor_descr(sensor_def, idx, descr_map, oid_index),
      sensor_unit: sensor_def[:sensor_unit] || "",
      sensor_divisor: sensor_def[:sensor_divisor] || 1,
      last_value: value / 1
    }
  end

  defp build_table_sensor(_sensor_def, _oid, _value, _idx, _descr_map, _oid_index), do: nil

  # Discover state sensors (sensors with discrete states like GPS Status, DFS Status)
  defp discover_state_sensors(sensor_defs, client_opts) do
    Enum.flat_map(sensor_defs, &walk_state_sensor(&1, client_opts))
  end

  defp walk_state_sensor(sensor_def, client_opts) do
    base_oid = sensor_def[:base_oid]

    case Client.walk(client_opts, base_oid) do
      {:ok, results} when is_map(results) and map_size(results) > 0 ->
        parse_state_walk_results(results, sensor_def)

      _ ->
        []
    end
  end

  defp parse_state_walk_results(results, sensor_def) when is_map(results) do
    results
    |> Enum.with_index(1)
    |> Enum.map(fn {{oid, value}, idx} ->
      # Extract the index from the OID for template substitution
      oid_index = oid |> String.split(".") |> List.last()
      build_state_sensor(sensor_def, oid, value, idx, oid_index)
    end)
    |> Enum.reject(&is_nil/1)
  end

  # Build a state sensor with value-to-description mapping
  defp build_state_sensor(sensor_def, oid, value, idx, oid_index) when is_number(value) do
    states = sensor_def[:states] || %{}
    # Convert value to integer for state lookup (states are discrete)
    int_value = trunc(value)
    # Map numeric state to description, or use the raw value
    state_descr = Map.get(states, int_value, "State #{int_value}")

    # Convert states map keys to strings for JSON storage
    states_for_json = Map.new(states, fn {k, v} -> {to_string(k), v} end)

    # Use index_template from YAML if available, otherwise generate default index
    sensor_index =
      case sensor_def[:index_template] do
        nil ->
          "#{sensor_def[:oid_name] || "state"}_#{idx}"

        template ->
          # Replace {{ $index }} with the actual OID index
          # Coerce to string since YAML parses bare numbers (e.g. `index: 0`) as integers
          template |> to_string() |> String.replace("{{ $index }}", oid_index)
      end

    %{
      sensor_type: "state",
      sensor_index: sensor_index,
      sensor_oid: oid,
      sensor_descr: build_sensor_descr_default(sensor_def, idx),
      sensor_unit: "",
      sensor_divisor: 1,
      last_value: value / 1,
      state_descr: state_descr,
      metadata: %{"states" => states_for_json}
    }
  end

  defp build_state_sensor(_sensor_def, _oid, _value, _idx, _oid_index), do: nil

  # Build sensor description, using the descr_map if available for actual device names
  defp build_sensor_descr(sensor_def, idx, descr_map, oid_index) do
    # First, try to get the description from the descr_map (walked from device)
    case Map.get(descr_map, oid_index) do
      descr when is_binary(descr) and descr != "" ->
        # Use the actual device name directly
        descr

      _ ->
        # Fall back to the default description building logic
        build_sensor_descr_default(sensor_def, idx)
    end
  end

  defp build_sensor_descr_default(sensor_def, idx) do
    base_descr = sensor_def[:sensor_descr] || sensor_def[:oid_name] || sensor_def[:sensor_type]

    # If the description contains a MIB symbolic name (e.g., "MIKROTIK-MIB::mtxrOpticalName"),
    # use the sensor_type as fallback since we can't resolve MIB names to actual values here
    base_descr =
      if is_binary(base_descr) and String.contains?(base_descr, "::") do
        sensor_def[:sensor_type] || "sensor"
      else
        base_descr
      end

    # If there's only one instance, don't add index
    if idx == 1 do
      String.capitalize(to_string(base_descr))
    else
      "#{String.capitalize(to_string(base_descr))} #{idx}"
    end
  end

  @doc """
  Collects all discovered sensor data for debugging purposes.
  Includes YAML-defined sensors (processors, state, count, table) and wireless sensors.
  """
  @spec collect_vendor_debug_data(map(), Client.connection_opts()) :: map()
  def collect_vendor_debug_data(profile, client_opts) do
    # Collect all sensors using the same discovery logic
    {:ok, all_sensors} = discover_sensors(profile, client_opts)

    # Format sensors for debug display with state descriptions
    discovered_sensors =
      Enum.map(all_sensors, fn sensor ->
        base = %{
          oid: sensor.sensor_oid,
          descr: sensor.sensor_descr,
          type: sensor.sensor_type,
          value: sensor.last_value,
          unit: sensor.sensor_unit || ""
        }

        # Add state description if present
        if Map.has_key?(sensor, :state_descr) do
          Map.put(base, :state_descr, sensor.state_descr)
        else
          base
        end
      end)

    # Also collect raw wireless OID values (including errors for debugging)
    wireless_oids = get_vendor_wireless_oids(profile)

    wireless_raw =
      Map.new(wireless_oids, fn sensor_def ->
        value =
          case Client.get(client_opts, sensor_def.oid) do
            {:ok, val} -> val
            {:error, reason} -> "ERROR: #{inspect(reason)}"
          end

        {sensor_def.sensor_type, %{oid: sensor_def.oid, descr: sensor_def.sensor_descr, value: value}}
      end)

    %{
      discovered_sensors: discovered_sensors,
      wireless_sensors: wireless_raw
    }
  end

  # Get the wireless OID definitions for a profile (for debug data collection)
  defp get_vendor_wireless_oids(%{name: name}) do
    VendorRegistry.get_wireless_oid_defs(name)
  end

  defp get_vendor_wireless_oids(_profile), do: []

  # Vendor-specific wireless sensor discovery using vendor modules
  defp discover_vendor_wireless_sensors(%{name: name}, client_opts) do
    VendorRegistry.discover_wireless_sensors(name, client_opts)
  end

  defp discover_vendor_wireless_sensors(_profile, _client_opts), do: []

  # Deduplicate sensors by OID - when multiple sensors share the same OID,
  # prefer the one with a proper description (not containing MIB symbolic names).
  # 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_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 symbolic name like "MIKROTIK-MIB::xxx")
      # Check case-insensitively since build_sensor_descr capitalizes only first letter
      Enum.find(group, fn s -> not has_mib_symbolic_name?(s.sensor_descr) end) ||
        List.last(group)
    end)
  end

  # Check if a description contains a MIB symbolic name (e.g., "MIKROTIK-MIB::mtxrOpticalName")
  # These should be filtered out in favor of actual sensor names from the device
  defp has_mib_symbolic_name?(descr) when is_binary(descr) do
    # MIB symbolic names contain "::" pattern (case-insensitive check)
    String.contains?(String.downcase(descr), "::")
  end

  defp has_mib_symbolic_name?(_), do: false

  # Applies vendor-specific post-processing to discovered sensors.
  # This allows vendors to enhance or transform sensors after base discovery.
  # For example, Arista converts DOM power sensors from watts to dBm and adds
  # vendor-specific thresholds.
  @spec apply_vendor_post_processing(map(), [map()], Client.connection_opts()) :: [map()]
  defp apply_vendor_post_processing(%{name: name} = _profile, sensors, client_opts) do
    case name do
      "arista_eos" ->
        # Apply Arista-specific enhancements
        alias Towerops.Snmp.Profiles.Vendors.Arista

        Arista.post_process_sensors(sensors, client_opts)

      "arista-mos" ->
        # Apply Arista-specific enhancements
        alias Towerops.Snmp.Profiles.Vendors.Arista

        Arista.post_process_sensors(sensors, client_opts)

      "dell-powervault" ->
        # Parse text-based sensor messages from FCMGMT-MIB
        alias Towerops.Snmp.Profiles.Vendors.Powervault

        Powervault.post_process_sensors(sensors, client_opts)

      _ ->
        # No vendor-specific post-processing
        sensors
    end
  end

  defp apply_vendor_post_processing(_profile, sensors, _client_opts), do: sensors
end
