defmodule Towerops.Profiles.YamlProfiles do
  @moduledoc """
  Loads and caches device profiles directly from YAML files.

  Profiles are stored in priv/profiles/ and loaded into an ETS table on startup.
  This avoids database storage and keeps YAML files as the source of truth.
  """

  use GenServer

  alias Towerops.Profiles.MibCache
  alias Towerops.Snmp.Client

  require Logger

  @table :yaml_profiles
  @profiles_dir "profiles"

  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @doc """
  Gets a profile by name from the cache.
  """
  def get_profile(name) when is_binary(name) do
    case :ets.lookup(@table, {:profile, name}) do
      [{_, profile}] -> profile
      [] -> nil
    end
  end

  @doc """
  Lists all cached profiles.
  """
  def list_profiles do
    @table
    |> :ets.match_object({{:profile, :_}, :_})
    |> Enum.map(fn {_, profile} -> profile end)
  end

  # Generic OS profiles that are checked last (like LibreNMS)
  # These are fallback profiles for common Linux-based devices
  @generic_os ~w(airos freebsd linux)

  @doc """
  Matches a profile based on system info (sysObjectID or sysDescr).
  Returns the best matching profile or nil.

  Uses LibreNMS's two-pass detection approach:
  1. First pass: Check profiles WITHOUT snmpget/snmpwalk conditions (except generic OS)
  2. Second pass: Check profiles WITH snmpget/snmpwalk conditions (evaluated via SNMP)
  3. Third pass: Check generic OS (airos, freebsd, linux) as final fallback
  """
  def match_profile(system_info, client_opts \\ []) do
    sys_object_id = Map.get(system_info, :sys_object_id, "")
    sys_descr = Map.get(system_info, :sys_descr, "")

    # First try to match by sysObjectID using LibreNMS's approach
    case match_by_oid_librenms_style(sys_object_id, sys_descr, client_opts) do
      nil -> match_by_descr(sys_descr)
      profile -> profile
    end
  end

  @doc """
  Reloads all profiles from YAML files.
  """
  def reload do
    GenServer.call(__MODULE__, :reload)
  end

  # GenServer callbacks

  @impl true
  def init(_opts) do
    # Create ETS table for profiles
    _ = :ets.new(@table, [:named_table, :set, :public, read_concurrency: true])

    # Load profiles and collect raw YAML data
    raw_profiles = load_all_profiles()

    # Extract MIB names and trigger asynchronous pre-resolution in MibCache
    # This doesn't block - MibCache will resolve in the background
    mib_names = extract_mib_names_from_profiles(raw_profiles)
    MibCache.pre_resolve(mib_names)

    {:ok, %{}}
  end

  @impl true
  def handle_call(:reload, _from, state) do
    :ets.delete_all_objects(@table)
    count = load_all_profiles()
    {:reply, {:ok, count}, state}
  end

  # Private functions

  defp load_all_profiles do
    profiles_path = Path.join(:code.priv_dir(:towerops), @profiles_dir)
    detection_path = Path.join(profiles_path, "os_detection")

    if File.exists?(detection_path) do
      yaml_files =
        detection_path
        |> Path.join("*.yaml")
        |> Path.wildcard()

      # Load profiles and collect raw detection YAMLs
      results =
        Enum.map(yaml_files, fn file ->
          name = Path.basename(file, ".yaml")
          {name, file, load_profile(profiles_path, name)}
        end)

      # Filter successful profiles and log errors
      raw_profiles =
        results
        |> Enum.filter(fn
          {name, file, {:error, error}} ->
            Logger.warning("Failed to load profile #{name} from #{file}: #{inspect(error)}")

            false

          {_name, _file, nil} ->
            false

          {_name, _file, _profile} ->
            true
        end)
        |> Enum.map(fn {_name, _file, profile} -> profile end)

      success_count = length(raw_profiles)
      total_count = length(yaml_files)

      Logger.info("Loaded #{success_count}/#{total_count} device profiles from YAML files")

      raw_profiles
    else
      Logger.debug("No profiles directory found at #{detection_path}")
      []
    end
  end

  defp load_profile(profiles_path, name) do
    detection_file = Path.join([profiles_path, "os_detection", "#{name}.yaml"])
    discovery_file = Path.join([profiles_path, "os_discovery", "#{name}.yaml"])

    with {:ok, detection} <- read_yaml(detection_file) do
      discovery = read_yaml_optional(discovery_file)

      profile = build_profile(name, detection, discovery)
      :ets.insert(@table, {{:profile, name}, profile})

      # Index by detection OID for fast lookups
      if profile.detection_oid do
        :ets.insert(@table, {{:oid, profile.detection_oid}, name})
      end

      # Index by detection patterns
      Enum.each(profile.detection_patterns, fn pattern ->
        :ets.insert(@table, {{:pattern, pattern}, name})
      end)

      # Return raw detection YAML for MIB name extraction
      detection
    end
  end

  defp build_profile(name, detection, discovery) do
    %{
      name: name,
      vendor: Map.get(detection, "text") || String.capitalize(name),
      detection_oid: extract_detection_oid(detection),
      detection_patterns: extract_detection_patterns(detection),
      # Compound detection: sysDescr/sysDescr_regex that must match alongside sysObjectID
      detection_descr_patterns: extract_detection_descr_patterns(detection),
      detection_descr_regex: extract_detection_descr_regex(detection),
      # Extract all detection blocks with their conditions for per-block matching
      detection_blocks: extract_detection_blocks(detection),
      device_oids: extract_device_oids(discovery),
      # Regex to extract hardware model from sysDescr (e.g., "/RouterOS (?<hardware>.*)/")
      hardware_regex: extract_hardware_regex(discovery),
      # Scalar sensors (direct OID polling)
      sensor_oids: extract_sensor_oids(discovery),
      # Table-based sensors (OID walks) - all standard sensor types
      table_sensor_oids: extract_table_sensor_oids(discovery),
      # Additional sensor types like LibreNMS
      processor_oids: extract_processor_oids(discovery),
      count_sensor_oids: extract_count_sensor_oids(discovery),
      state_sensor_oids: extract_state_sensor_oids(discovery)
    }
  end

  defp extract_detection_oid(yaml) do
    case get_in(yaml, ["discovery"]) do
      discovery when is_list(discovery) ->
        # Extract OIDs from all discovery blocks, including those with snmpget/snmpwalk conditions
        # We can't evaluate snmpget conditions, but we can still match on OID + sysDescr constraints
        # This allows profiles like airos (which has snmpget condition) to still match by OID
        discovery
        |> Enum.flat_map(fn
          %{"sysObjectID" => patterns} when is_list(patterns) -> patterns
          %{"sysObjectID" => pattern} when is_binary(pattern) -> [pattern]
          _ -> []
        end)
        |> List.first()
        |> normalize_oid()

      _ ->
        nil
    end
  end

  # Extract all detection blocks with their OIDs, sysDescr constraints, and condition flags
  # This enables per-block matching (a profile may have multiple detection blocks)
  defp extract_detection_blocks(yaml) do
    case get_in(yaml, ["discovery"]) do
      discovery when is_list(discovery) ->
        Enum.flat_map(discovery, &parse_discovery_block/1)

      _ ->
        []
    end
  end

  defp parse_discovery_block(block) do
    oids = extract_oids_from_block(block)
    has_snmpget = Map.has_key?(block, "snmpget")
    has_snmpwalk = Map.has_key?(block, "snmpwalk")
    descr_patterns = extract_descr_from_block(block)
    descr_regex = extract_descr_regex_from_block(block)
    snmpget_condition = extract_snmpget_condition(block)
    snmpwalk_condition = extract_snmpwalk_condition(block)

    Enum.map(oids, fn oid ->
      %{
        oid: normalize_oid(oid),
        has_snmpget: has_snmpget,
        has_snmpwalk: has_snmpwalk,
        # Both snmpget and snmpwalk are supported conditional checks
        has_condition: has_snmpget || has_snmpwalk,
        descr_patterns: descr_patterns,
        descr_regex: descr_regex,
        snmpget: snmpget_condition,
        snmpwalk: snmpwalk_condition
      }
    end)
  end

  defp extract_oids_from_block(block) do
    case Map.get(block, "sysObjectID") do
      patterns when is_list(patterns) -> patterns
      pattern when is_binary(pattern) -> [pattern]
      _ -> []
    end
  end

  defp extract_descr_from_block(block) do
    case Map.get(block, "sysDescr") do
      patterns when is_list(patterns) -> patterns
      pattern when is_binary(pattern) -> [pattern]
      _ -> []
    end
  end

  defp extract_descr_regex_from_block(block) do
    case Map.get(block, "sysDescr_regex") do
      patterns when is_list(patterns) ->
        patterns |> Enum.map(&parse_regex_pattern/1) |> Enum.reject(&is_nil/1)

      pattern when is_binary(pattern) ->
        case parse_regex_pattern(pattern) do
          nil -> []
          regex -> [regex]
        end

      _ ->
        []
    end
  end

  defp extract_snmpget_condition(block) do
    case Map.get(block, "snmpget") do
      %{"oid" => oid, "value" => value} = condition ->
        # Default operator is 'contains' like LibreNMS
        %{oid: oid, op: Map.get(condition, "op", "contains"), value: value}

      _ ->
        nil
    end
  end

  defp extract_snmpwalk_condition(block) do
    case Map.get(block, "snmpwalk") do
      %{"oid" => oid, "value" => value} = condition ->
        # Default operator is 'contains' like LibreNMS
        %{oid: oid, op: Map.get(condition, "op", "contains"), value: value}

      _ ->
        nil
    end
  end

  # Strip leading dot from OID (LibreNMS uses .1.3.6... but SNMP returns 1.3.6...)
  defp normalize_oid(nil), do: nil
  defp normalize_oid("." <> rest), do: rest
  defp normalize_oid(oid), do: oid

  defp extract_detection_patterns(yaml) do
    case get_in(yaml, ["discovery"]) do
      discovery when is_list(discovery) ->
        Enum.flat_map(discovery, fn
          %{"sysDescr" => patterns} when is_list(patterns) -> patterns
          _ -> []
        end)

      _ ->
        []
    end
  end

  # Extract sysDescr patterns that must match alongside sysObjectID (compound detection)
  defp extract_detection_descr_patterns(yaml) do
    case get_in(yaml, ["discovery"]) do
      discovery when is_list(discovery) ->
        # Only extract from discovery blocks that have BOTH sysObjectID and sysDescr
        Enum.flat_map(discovery, fn
          %{"sysObjectID" => _, "sysDescr" => patterns} when is_list(patterns) -> patterns
          %{"sysObjectID" => _, "sysDescr" => pattern} when is_binary(pattern) -> [pattern]
          _ -> []
        end)

      _ ->
        []
    end
  end

  # Extract sysDescr_regex patterns that must match alongside sysObjectID (compound detection)
  defp extract_detection_descr_regex(yaml) do
    case get_in(yaml, ["discovery"]) do
      discovery when is_list(discovery) ->
        # Only extract from discovery blocks that have BOTH sysObjectID and sysDescr_regex
        discovery
        |> Enum.flat_map(fn
          %{"sysObjectID" => _, "sysDescr_regex" => patterns} when is_list(patterns) ->
            Enum.map(patterns, &parse_regex_pattern/1)

          _ ->
            []
        end)
        |> Enum.reject(&is_nil/1)

      _ ->
        []
    end
  end

  # Parse LibreNMS regex pattern format: '/pattern/' or '/pattern/i'
  defp parse_regex_pattern(pattern) when is_binary(pattern) do
    case Regex.run(~r/^\/(.+)\/([i]?)$/, pattern) do
      [_, regex_str, "i"] ->
        case Regex.compile(regex_str, [:caseless]) do
          {:ok, regex} -> regex
          _ -> nil
        end

      [_, regex_str, ""] ->
        case Regex.compile(regex_str) do
          {:ok, regex} -> regex
          _ -> nil
        end

      _ ->
        nil
    end
  end

  defp parse_regex_pattern(_), do: nil

  defp extract_device_oids(yaml) do
    os_module = get_in(yaml, ["modules", "os"]) || %{}

    field_mappings = [
      {"serial", :serial_number},
      {"version", :firmware_version},
      {"features", :license_version},
      {"hardware", :hardware},
      {"lat", :latitude},
      {"long", :longitude}
    ]

    field_mappings
    |> Enum.flat_map(fn {yaml_field, field} ->
      case Map.get(os_module, yaml_field) do
        nil -> []
        mib_names when is_list(mib_names) -> [{field, List.first(mib_names)}]
        mib_name when is_binary(mib_name) -> [{field, mib_name}]
        _ -> []
      end
    end)
    |> Map.new()
  end

  # Extract sysDescr_regex from os module for hardware extraction
  # Returns a compiled regex if present, nil otherwise
  defp extract_hardware_regex(yaml) do
    case get_in(yaml, ["modules", "os", "sysDescr_regex"]) do
      pattern when is_binary(pattern) ->
        parse_regex_pattern(pattern)

      _ ->
        nil
    end
  end

  # All sensor types supported by LibreNMS
  @sensor_types ~w(temperature voltage current power fanspeed humidity frequency
                   dbm signal snr airflow load runtime waterflow pressure cooling charge)

  # Extract scalar sensors (direct OID polling with .0 suffix)
  defp extract_sensor_oids(yaml) do
    sensors_module = get_in(yaml, ["modules", "sensors"]) || %{}

    Enum.flat_map(@sensor_types, fn sensor_type ->
      case get_in(sensors_module, [sensor_type, "data"]) do
        data when is_list(data) ->
          data
          |> Enum.map(&parse_scalar_sensor(&1, sensor_type))
          |> Enum.reject(&is_nil/1)

        _ ->
          []
      end
    end)
  end

  # Extract table-based sensors (OID walks with {{ $index }})
  defp extract_table_sensor_oids(yaml) do
    sensors_module = get_in(yaml, ["modules", "sensors"]) || %{}

    Enum.flat_map(@sensor_types, fn sensor_type ->
      case get_in(sensors_module, [sensor_type, "data"]) do
        data when is_list(data) ->
          data
          |> Enum.map(&parse_table_sensor_def(&1, sensor_type))
          |> Enum.reject(&is_nil/1)

        _ ->
          []
      end
    end)
  end

  # Parse a scalar sensor (direct OID ending in .0)
  defp parse_scalar_sensor(sensor_def, sensor_type) do
    mib_name = Map.get(sensor_def, "oid") || Map.get(sensor_def, "value")
    num_oid = Map.get(sensor_def, "num_oid")

    # Only include if it's a scalar sensor (no {{ $index }} and ends with .0)
    is_table = is_binary(num_oid) and String.contains?(num_oid, "{{ $index }}")
    is_scalar = is_binary(mib_name) and String.ends_with?(mib_name, ".0")

    if !is_table and is_scalar do
      %{
        sensor_type: sensor_type,
        mib_name: mib_name,
        sensor_descr: extract_descr(sensor_def, sensor_type),
        sensor_unit: Map.get(sensor_def, "unit"),
        sensor_divisor: Map.get(sensor_def, "divisor", 1)
      }
    end
  end

  # Parse a table-based sensor definition (has {{ $index }} placeholder)
  defp parse_table_sensor_def(sensor_def, sensor_type) do
    num_oid = Map.get(sensor_def, "num_oid")
    oid_name = Map.get(sensor_def, "oid")

    # Only include if it's a table sensor (has {{ $index }})
    if is_binary(num_oid) and String.contains?(num_oid, "{{ $index }}") do
      base_oid = extract_base_oid(num_oid)

      if base_oid do
        %{
          sensor_type: sensor_type,
          base_oid: base_oid,
          oid_name: oid_name,
          sensor_descr: extract_descr(sensor_def, sensor_type),
          # Store the descr MIB OID reference for walking to get actual names
          descr_oid: extract_descr_oid(sensor_def),
          sensor_unit: Map.get(sensor_def, "unit"),
          sensor_divisor: Map.get(sensor_def, "divisor", 1),
          precision: Map.get(sensor_def, "precision", 1),
          index_template: Map.get(sensor_def, "index"),
          table: true
        }
      end
    end
  end

  defp extract_descr(sensor_def, default) do
    case Map.get(sensor_def, "descr") do
      nil ->
        default

      descr when is_binary(descr) ->
        # Remove template variables like {{ MIKROTIK-MIB::mtxrOpticalName }}
        cleaned = descr |> String.replace(~r/\{\{ [^}]+ \}\}/, "") |> String.trim()

        # If the cleaned descr is a MIB symbolic name (contains "::"), use the default
        # MIB names like "MIKROTIK-MIB::mtxrOpticalName" should be resolved to actual values
        # by vendor modules, not used as literal descriptions
        cond do
          cleaned == "" -> default
          String.contains?(cleaned, "::") -> default
          true -> cleaned
        end

      _ ->
        default
    end
  end

  # Extract the descr MIB OID reference when descr contains a MIB symbolic name
  # This allows the dynamic profile to walk the descr OID and get actual sensor names
  defp extract_descr_oid(sensor_def) do
    case Map.get(sensor_def, "descr") do
      descr when is_binary(descr) ->
        # Remove template variables first
        cleaned = descr |> String.replace(~r/\{\{ [^}]+ \}\}/, "") |> String.trim()

        # If it's a MIB symbolic name (contains "::"), return it for later resolution
        if String.contains?(cleaned, "::"), do: cleaned

      _ ->
        nil
    end
  end

  # Extract processor/CPU sensors (like LibreNMS processors module)
  defp extract_processor_oids(yaml) do
    case get_in(yaml, ["modules", "processors", "data"]) do
      data when is_list(data) ->
        data
        |> Enum.map(&parse_table_sensor(&1, "processor", "%"))
        |> Enum.reject(&is_nil/1)

      _ ->
        []
    end
  end

  # Extract count sensors (like LibreNMS sensors.count module)
  defp extract_count_sensor_oids(yaml) do
    case get_in(yaml, ["modules", "sensors", "count", "data"]) do
      data when is_list(data) ->
        data
        |> Enum.map(&parse_table_sensor(&1, "count", ""))
        |> Enum.reject(&is_nil/1)

      _ ->
        []
    end
  end

  # Extract state sensors (like LibreNMS sensors.state module)
  defp extract_state_sensor_oids(yaml) do
    case get_in(yaml, ["modules", "sensors", "state", "data"]) do
      data when is_list(data) ->
        data
        |> Enum.map(&parse_state_sensor/1)
        |> Enum.reject(&is_nil/1)

      _ ->
        []
    end
  end

  # Parse a table-based sensor (walks the OID table to find all instances)
  defp parse_table_sensor(sensor_def, sensor_type, default_unit) do
    num_oid = Map.get(sensor_def, "num_oid")
    oid_name = Map.get(sensor_def, "oid")

    base_oid = extract_base_oid(num_oid)

    if base_oid do
      %{
        sensor_type: sensor_type,
        base_oid: base_oid,
        oid_name: oid_name,
        sensor_descr: extract_descr(sensor_def, sensor_type),
        sensor_unit: Map.get(sensor_def, "unit") || default_unit,
        sensor_divisor: Map.get(sensor_def, "divisor", 1),
        precision: Map.get(sensor_def, "precision", 1),
        index_template: Map.get(sensor_def, "index"),
        table: true
      }
    end
  end

  # Parse a state sensor with discrete values
  defp parse_state_sensor(sensor_def) do
    num_oid = Map.get(sensor_def, "num_oid")
    oid_name = Map.get(sensor_def, "oid")
    states = Map.get(sensor_def, "states", [])

    base_oid = extract_base_oid(num_oid)

    if base_oid do
      # Build state mapping: value => description
      state_map =
        Map.new(states, fn state ->
          {Map.get(state, "value"), Map.get(state, "descr", "Unknown")}
        end)

      %{
        sensor_type: "state",
        base_oid: base_oid,
        oid_name: oid_name,
        sensor_descr: extract_descr(sensor_def, "state"),
        sensor_unit: "",
        states: state_map,
        index_template: Map.get(sensor_def, "index"),
        table: true
      }
    end
  end

  # Extract base OID from num_oid by removing the {{ $index }} placeholder
  defp extract_base_oid(nil), do: nil

  defp extract_base_oid(num_oid) when is_binary(num_oid) do
    # Remove {{ $index }} and trailing dot, clean up the OID
    num_oid
    |> String.replace(~r/\.\{\{\s*\$index\s*\}\}/, "")
    |> String.replace(~r/^\.*/, "")
    |> case do
      "" -> nil
      oid -> oid
    end
  end

  # LibreNMS-style OS detection with proper priority ordering
  # 1. First pass: Match profiles by unconditional blocks (no snmpget/snmpwalk), excluding generic_os
  # 2. Second pass: Match profiles by conditional blocks (snmpget/snmpwalk with cached MIB resolution)
  # 3. Third pass: Generic OS (airos, freebsd, linux) with unconditional blocks as fallback
  # 4. Fourth pass: Generic OS with conditional blocks (last resort for profiles like airos)
  #
  # We check per-block, not per-profile. A profile may have both conditional and unconditional
  # blocks for different OIDs (e.g., ibm-imm has snmpget block for one OID and plain block for another)
  defp match_by_oid_librenms_style(sys_object_id, sys_descr, client_opts)
       when is_binary(sys_object_id) and sys_object_id != "" do
    all_profiles = list_profiles()

    # Categorize profiles
    {generic_profiles, other_profiles} =
      Enum.split_with(all_profiles, fn p -> p.name in @generic_os end)

    # Try matching in priority order, return first match
    # LibreNMS-style 4-phase matching: unconditional, then conditional (with cached MIB resolution)
    # MIB names are pre-resolved at startup and cached in ETS for instant lookups (<1µs)
    try_profile_matches([
      # First pass: non-generic profiles with unconditional matching blocks
      fn -> find_best_unconditional_match(other_profiles, sys_object_id, sys_descr) end,
      # Second pass: non-generic profiles with conditional blocks (snmpget/snmpwalk)
      fn -> find_best_conditional_match(other_profiles, sys_object_id, sys_descr, client_opts) end,
      # Third pass: generic OS with unconditional blocks
      fn -> find_best_unconditional_match(generic_profiles, sys_object_id, sys_descr) end,
      # Fourth pass: generic OS with conditional blocks
      fn -> find_best_conditional_match(generic_profiles, sys_object_id, sys_descr, client_opts) end
    ])
  end

  defp match_by_oid_librenms_style(_, _, _), do: nil

  defp try_profile_matches([]), do: nil

  defp try_profile_matches([matcher | rest]) do
    case matcher.() do
      nil -> try_profile_matches(rest)
      profile -> profile
    end
  end

  # Find best match using only unconditional detection blocks (no snmpget/snmpwalk)
  defp find_best_unconditional_match(profiles, sys_object_id, sys_descr) do
    find_best_block_match(profiles, sys_object_id, sys_descr, false, [])
  end

  # Find best match using conditional detection blocks (has snmpget/snmpwalk)
  # MIB names are pre-resolved at startup and cached for fast lookups
  defp find_best_conditional_match(profiles, sys_object_id, sys_descr, client_opts) do
    profile_names = Enum.map(profiles, & &1.name)

    Logger.debug("Attempting conditional profile match with cached MIB resolution",
      sys_object_id: sys_object_id,
      sys_descr: sys_descr,
      profile_count: length(profiles),
      profiles: Enum.join(profile_names, ", ")
    )

    find_best_block_match(profiles, sys_object_id, sys_descr, true, client_opts)
  end

  # Find the best matching profile based on detection blocks
  defp find_best_block_match(profiles, sys_object_id, sys_descr, match_conditional, client_opts) do
    profiles
    |> Enum.flat_map(&find_profile_matching_blocks(&1, sys_object_id, sys_descr, match_conditional, client_opts))
    |> select_best_match()
  end

  defp find_profile_matching_blocks(profile, sys_object_id, sys_descr, match_conditional, client_opts) do
    # Log when checking profiles with conditional blocks
    conditional_blocks = Enum.filter(profile.detection_blocks, fn block -> block.has_condition end)

    if match_conditional && conditional_blocks != [] do
      Logger.debug("Checking profile with conditional blocks",
        profile_name: profile.name,
        conditional_blocks: length(conditional_blocks),
        total_blocks: length(profile.detection_blocks)
      )
    end

    matching_blocks =
      Enum.filter(profile.detection_blocks, fn block ->
        matches = block_matches?(block, sys_object_id, sys_descr, match_conditional, client_opts)

        if matches && match_conditional do
          Logger.debug("Profile matched with conditional check",
            profile_name: profile.name,
            oid: block.oid,
            has_snmpget: block.has_snmpget,
            has_snmpwalk: block.has_snmpwalk
          )
        end

        matches
      end)

    case matching_blocks do
      [] -> []
      blocks -> [{profile, Enum.max_by(blocks, &String.length(&1.oid || ""))}]
    end
  end

  defp block_matches?(block, sys_object_id, sys_descr, match_conditional, client_opts) do
    oid_match = block.oid && String.contains?(sys_object_id, block.oid)
    condition_match = block.has_condition == match_conditional
    descr_match = block_matches_descr?(block, sys_descr)
    snmpget_match = block_matches_snmpget?(block, match_conditional, client_opts)

    matches = oid_match && condition_match && descr_match && snmpget_match

    if oid_match && condition_match do
      Logger.debug("Block evaluation",
        oid: block.oid,
        has_snmpget: block.has_snmpget,
        has_snmpwalk: block.has_snmpwalk,
        match_conditional: match_conditional,
        oid_match: oid_match,
        descr_match: descr_match,
        snmpget_match: snmpget_match,
        overall_match: matches
      )
    end

    matches
  end

  defp select_best_match([]), do: nil

  defp select_best_match(matches) do
    {profile, _block} = Enum.max_by(matches, fn {_p, block} -> String.length(block.oid || "") end)
    profile
  end

  # Check if a detection block's sysDescr constraints are satisfied
  defp block_matches_descr?(block, sys_descr) do
    patterns = block.descr_patterns || []
    regexes = block.descr_regex || []

    cond do
      # No constraints - OID match is sufficient
      patterns == [] and regexes == [] ->
        true

      # sys_descr is nil - cannot match patterns/regexes
      is_nil(sys_descr) ->
        false

      # Has pattern constraints - must match at least one
      patterns != [] ->
        Enum.any?(patterns, fn pattern -> String.contains?(sys_descr, pattern) end)

      # Has regex constraints - must match at least one
      regexes != [] ->
        Enum.any?(regexes, fn regex -> Regex.match?(regex, sys_descr) end)
    end
  end

  # Check if a detection block's snmpget/snmpwalk conditions are satisfied
  defp block_matches_snmpget?(block, match_conditional, client_opts) do
    cond do
      # No conditions - always matches
      is_nil(block.snmpget) && is_nil(block.snmpwalk) ->
        true

      # Not in conditional matching mode - skip evaluation
      !match_conditional ->
        true

      # Evaluate snmpget condition via SNMP query
      !is_nil(block.snmpget) ->
        evaluate_snmpget_condition(block.snmpget, client_opts)

      # Evaluate snmpwalk condition via SNMP walk
      !is_nil(block.snmpwalk) ->
        evaluate_snmpwalk_condition(block.snmpwalk, client_opts)

      # Default: no match
      true ->
        false
    end
  end

  # Evaluate a snmpget condition by performing SNMP query
  defp evaluate_snmpget_condition(%{oid: oid, op: op, value: expected}, client_opts) do
    # Resolve MIB name to numeric OID using MibCache
    # MibCache handles cache lookups and runtime resolution automatically
    numeric_oid = MibCache.lookup(oid)

    Logger.debug("Evaluating snmpget for profile matching: oid=#{numeric_oid}")
    start_time = System.monotonic_time(:millisecond)

    # Query the OID via SNMP
    result =
      case Client.get(client_opts, numeric_oid) do
        {:ok, actual} ->
          match_result = compare_snmp_values(actual, op, expected)
          duration = System.monotonic_time(:millisecond) - start_time
          Logger.debug("snmpget succeeded in #{duration}ms: matches=#{match_result}")
          match_result

        {:error, reason} ->
          duration = System.monotonic_time(:millisecond) - start_time

          Logger.debug("snmpget failed after #{duration}ms: #{inspect(reason)}")

          # If checking "= false", treat missing OID as false (matches LibreNMS fallback behavior)
          # If checking "!= false", missing OID means no match (OID must exist)
          op == "=" && expected == false
      end

    result
  end

  # Evaluate a snmpwalk condition by performing SNMP walk
  defp evaluate_snmpwalk_condition(%{oid: oid, op: op, value: expected}, client_opts) do
    # Resolve MIB name to numeric OID using MibCache
    # MibCache handles cache lookups and runtime resolution automatically
    numeric_oid = MibCache.lookup(oid)

    Logger.debug("Evaluating snmpwalk condition: oid=#{numeric_oid}, op=#{op}, expected=#{inspect(expected)}")

    # Walk the OID via SNMP to get all values
    case Client.walk(client_opts, numeric_oid) do
      {:ok, results} when is_map(results) ->
        # Extract all values from the walk results map
        values = Map.values(results)
        # Check if any value matches the condition
        result = Enum.any?(values, fn value -> compare_snmp_values(value, op, expected) end)
        Logger.debug("snmpwalk result: found #{length(values)} values, matches=#{result}")
        result

      {:error, reason} ->
        Logger.debug("snmpwalk query failed: #{inspect(reason)}")
        false
    end
  end

  # Compare SNMP values using LibreNMS operators
  defp compare_snmp_values(actual, "=", expected), do: to_string(actual) == to_string(expected)
  defp compare_snmp_values(actual, "!=", expected), do: to_string(actual) != to_string(expected)
  defp compare_snmp_values(actual, "starts", expected), do: String.starts_with?(to_string(actual), to_string(expected))
  defp compare_snmp_values(actual, "contains", expected), do: String.contains?(to_string(actual), to_string(expected))

  defp compare_snmp_values(actual, "regex", expected) do
    case Regex.compile(to_string(expected)) do
      {:ok, regex} -> Regex.match?(regex, to_string(actual))
      _ -> false
    end
  end

  defp compare_snmp_values(actual, ">=", expected) do
    # Try to parse as numbers for numeric comparison
    case {parse_number(actual), parse_number(expected)} do
      {actual_num, expected_num} when is_number(actual_num) and is_number(expected_num) ->
        actual_num >= expected_num

      _ ->
        # Fallback to string comparison
        to_string(actual) >= to_string(expected)
    end
  end

  defp compare_snmp_values(_actual, _op, _expected), do: false

  # Parse value as number (integer or float)
  defp parse_number(value) when is_number(value), do: value

  defp parse_number(value) when is_binary(value) do
    case Integer.parse(value) do
      {num, ""} ->
        num

      _ ->
        case Float.parse(value) do
          {num, ""} -> num
          _ -> nil
        end
    end
  end

  defp parse_number(_), do: nil

  # Pre-resolve all MIB names from profiles at startup
  # Extract all MIB names from loaded profiles (for pre-resolution)
  defp extract_mib_names_from_profiles(profiles) do
    profiles
    |> Enum.flat_map(fn profile ->
      # Extract from detection blocks
      Enum.flat_map(profile["discovery"] || [], fn block ->
        extract_mib_names_from_block(block)
      end)
    end)
    |> Enum.uniq()
  end

  # Extract MIB names from a single detection block
  defp extract_mib_names_from_block(block) when is_map(block) do
    mib_names = []

    # Extract from snmpget.oid
    mib_names =
      if get_in(block, ["snmpget", "oid"]) do
        [get_in(block, ["snmpget", "oid"]) | mib_names]
      else
        mib_names
      end

    # Extract from snmpwalk.oid
    mib_names =
      if get_in(block, ["snmpwalk", "oid"]) do
        [get_in(block, ["snmpwalk", "oid"]) | mib_names]
      else
        mib_names
      end

    # Filter only MIB names (contain "::")
    Enum.filter(mib_names, &String.contains?(&1, "::"))
  end

  defp extract_mib_names_from_block(_), do: []

  defp match_by_descr(sys_descr) when is_binary(sys_descr) and sys_descr != "" do
    Enum.find(list_profiles(), fn profile ->
      Enum.any?(profile.detection_patterns, fn pattern ->
        Regex.match?(~r/#{Regex.escape(pattern)}/i, sys_descr)
      end)
    end)
  end

  defp match_by_descr(_), do: nil

  defp read_yaml(file_path) do
    with {:ok, content} <- File.read(file_path),
         {:ok, data} <- YamlElixir.read_from_string(content) do
      data =
        case data do
          [d] -> d
          d when is_map(d) -> d
          _ -> data
        end

      {:ok, data}
    end
  end

  defp read_yaml_optional(file_path) do
    case read_yaml(file_path) do
      {:ok, data} -> data
      _ -> %{}
    end
  end
end
