defmodule SnmpKit.SnmpMgr.Core do
  @moduledoc """
  Core SNMP operations using Erlang's SNMP PDU functions directly.

  This module handles the low-level SNMP PDU encoding/decoding and UDP communication
  without requiring the heavyweight :snmpm manager process.

  ## Timeout Behavior

  All functions in this module use a single timeout parameter that controls
  the SNMP PDU timeout - how long to wait for a response to each individual
  SNMP packet sent to the target device.

  - **Default timeout**: 10 seconds (10,000 milliseconds)
  - **Timeout applies to**: Each individual SNMP PDU (GET, SET, GETBULK, etc.)
  - **Not applicable to**: Multi-PDU operations (use walk functions for those)

  For operations that may require multiple PDUs (like walking large tables),
  consider using the higher-level walk functions in `SnmpKit.SnmpMgr.MultiV2`
  which handle multi-PDU timeouts appropriately.
  """

  alias SnmpKit.SnmpLib.Manager
  alias SnmpKit.SnmpLib.OID
  alias SnmpKit.SnmpMgr.MIB
  alias SnmpKit.SnmpMgr.Target

  @type snmp_result :: {:ok, term()} | {:error, atom() | tuple()}
  @type target :: binary() | tuple() | map()
  @type oid :: binary() | list(non_neg_integer())
  @type opts :: keyword()

  # Type normalization map for manager-expected atoms
  @type_normalization %{
    :string => :string,
    :octet_string => :string,
    :octetString => :string,
    :integer => :integer,
    :unsigned32 => :integer,
    :gauge32 => :gauge32,
    :counter32 => :counter32,
    :counter64 => :counter64,
    :timeticks => :timeticks,
    :timeTicks => :timeticks,
    :ip_address => :ip_address,
    :ipAddress => :ip_address,
    :object_identifier => :object_identifier,
    :objectId => :object_identifier,
    :oid => :object_identifier,
    :null => :null,
    :opaque => :opaque
  }

  @doc """
  Sends an SNMP GET request and returns the response.

  ## Parameters
  - `target` - SNMP target (host, "host:port", or target map)
  - `oid` - Object identifier (string or list format)
  - `opts` - Request options
    - `:timeout` - SNMP PDU timeout in milliseconds (default: 10000)
    - `:community` - SNMP community string (default: "public")
    - `:version` - SNMP version (:v1, :v2c) (default: :v2c)
    - `:port` - SNMP port (default: 161)
  """
  @spec send_get_request(target(), oid(), opts()) :: snmp_result()
  def send_get_request(target, oid, opts \\ []) do
    # Parse target to extract host and port
    {host, updated_opts} =
      case Target.parse(target) do
        {:ok, %{host: host, port: port}} ->
          # Only use parsed port if the target actually contained a port specification
          if target_contains_port?(target) do
            # Target contained port - use parsed port
            opts_with_port = Keyword.put(opts, :port, port)
            {host, opts_with_port}
          else
            # Target didn't contain port - preserve user's port option
            {host, opts}
          end

        {:error, _reason} ->
          # Failed to parse, use as-is
          {target, opts}
      end

    # Convert oid to proper format
    oid_parsed =
      case parse_oid(oid) do
        {:ok, oid_list} -> oid_list
        {:error, _} -> oid
      end

    # Map options to snmp_lib format
    snmp_lib_opts = map_options_to_snmp_lib(updated_opts)

    # Use SnmpKit.SnmpLib.Manager for the actual operation
    case Manager.get(host, oid_parsed, snmp_lib_opts) do
      {:ok, {type, value}} ->
        {:ok, {type, value}}

      # Type information must be preserved - reject responses without type information
      {:ok, value} ->
        {:error,
         {:type_information_lost,
          "SNMP GET operation must preserve type information. Got value without type: #{inspect(value)}"}}

      {:error, reason} ->
        {:error, reason}
    end
  end

  @doc """
  Sends a GET request and returns the result in 3-tuple format.

  This function returns `{oid_string, type, value}` for consistency with
  other operations like walk, bulk, etc.
  """
  @spec send_get_request_with_type(target(), oid(), opts()) ::
          {:ok, {String.t(), atom(), any()}} | {:error, any()}
  def send_get_request_with_type(target, oid, opts \\ []) do
    # Parse target to extract host and port
    {host, updated_opts} =
      case Target.parse(target) do
        {:ok, %{host: host, port: port}} ->
          # Use parsed port, overriding any default
          opts_with_port = Keyword.put(opts, :port, port)
          {host, opts_with_port}

        {:error, _reason} ->
          # Failed to parse, use as-is
          {target, opts}
      end

    # Convert oid to proper format - always work with lists internally
    oid_parsed =
      case parse_oid(oid) do
        {:ok, oid_list} -> oid_list
        # Safe fallback
        {:error, _} -> [1, 3]
      end

    # Generate string representation only for final response
    oid_string = Enum.join(oid_parsed, ".")

    # Map options to snmp_lib format
    snmp_lib_opts = map_options_to_snmp_lib(updated_opts)

    case Manager.get(host, oid_parsed, snmp_lib_opts) do
      {:ok, {type, value}} ->
        {:ok, {oid_string, type, value}}

      {:ok, value} ->
        # Type information must be preserved - reject responses without type information
        {:error,
         {:type_information_lost,
          "SNMP GET operation must preserve type information. Got value without type for OID #{oid_string}: #{inspect(value)}"}}

      {:error, reason} ->
        {:error, reason}
    end
  end

  @doc """
  Sends a GETNEXT request to retrieve the next OID in the MIB tree.

  Now uses the proper SnmpKit.SnmpLib.Manager.get_next/3 function which handles
  version-specific logic (GETNEXT for v1, GETBULK for v2c+) correctly.
  """
  @spec send_get_next_request(target(), oid(), opts()) :: snmp_result()
  def send_get_next_request(target, oid, opts \\ []) do
    # Parse target to extract host and port
    {host, updated_opts} =
      case Target.parse(target) do
        {:ok, %{host: host, port: port}} ->
          # Use parsed port, overriding any default
          opts_with_port = Keyword.put(opts, :port, port)
          {host, opts_with_port}

        {:error, _reason} ->
          # Failed to parse, use as-is
          {target, opts}
      end

    # Convert oid to proper format
    oid_parsed =
      case parse_oid(oid) do
        {:ok, oid_list} -> oid_list
        {:error, _} -> oid
      end

    # Map options to snmp_lib format
    snmp_lib_opts = map_options_to_snmp_lib(updated_opts)

    # Use the new SnmpKit.SnmpLib.Manager.get_next function which properly handles version logic
    case Manager.get_next(host, oid_parsed, snmp_lib_opts) do
      {:ok, {next_oid, type, value}} ->
        # ALWAYS preserve type information - this is critical for SNMP
        # Convert OID to string only for final response format
        next_oid_string =
          if is_list(next_oid) do
            Enum.join(next_oid, ".")
          else
            # Should not happen, but handle gracefully
            to_string(next_oid)
          end

        {:ok, {next_oid_string, type, value}}

      {:error, reason} ->
        {:error, reason}
    end
  end

  @doc """
  Sends an SNMP SET request and returns the response.

  ## Parameters
  - `target` - SNMP target (host, "host:port", or target map)
  - `oid` - Object identifier to set
  - `value` - Value to set (will be encoded based on type)
  - `opts` - Request options
    - `:timeout` - SNMP PDU timeout in milliseconds (default: 10000)
    - `:community` - SNMP community string (default: "public")
    - `:version` - SNMP version (:v1, :v2c) (default: :v2c)
  """
  @spec send_set_request(target(), oid(), term(), opts()) :: snmp_result()
  def send_set_request(target, oid, value, opts \\ []) do
    {host, updated_opts} = parse_target_with_port(target, opts)
    oid_parsed = parse_oid_or_fallback(oid)
    typed_value = convert_value_to_typed(value)
    snmp_lib_opts = map_options_to_snmp_lib(updated_opts)

    case Manager.set(host, oid_parsed, typed_value, snmp_lib_opts) do
      {:ok, result} -> {:ok, result}
      {:error, reason} -> {:error, reason}
    end
  end

  defp parse_target_with_port(target, opts) do
    case Target.parse(target) do
      {:ok, %{host: host, port: port}} ->
        if target_contains_port?(target) do
          {host, Keyword.put(opts, :port, port)}
        else
          {host, opts}
        end

      {:error, _reason} ->
        {target, opts}
    end
  end

  defp parse_oid_or_fallback(oid) do
    case parse_oid(oid) do
      {:ok, oid_list} -> oid_list
      {:error, _} -> oid
    end
  end

  defp convert_value_to_typed(value) do
    cond do
      match?({t, _v} when is_atom(t), value) -> normalize_manager_typed(value)
      is_binary(value) -> {:string, value}
      is_integer(value) -> {:integer, value}
      ipv4_tuple?(value) -> {:ip_address, value}
      true -> {:opaque, value}
    end
  end

  defp ipv4_tuple?({a, b, c, d}) when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d), do: true

  defp ipv4_tuple?(_), do: false

  # Normalize typed values to the atoms that SnmpKit.SnmpLib.Manager expects
  defp normalize_manager_typed({type, val}) when is_atom(type) do
    mapped_type = Map.get(@type_normalization, type, type)
    {mapped_type, val}
  end

  # Extract varbinds from get_bulk results
  defp extract_varbinds(%{"varbinds" => varbinds}) when is_list(varbinds), do: varbinds
  defp extract_varbinds(results) when is_list(results), do: results
  defp extract_varbinds(_), do: []

  @doc """
  Sends an SNMP GETBULK request (SNMPv2c only).

  GETBULK is more efficient than multiple GETNEXT operations for retrieving
  multiple consecutive OIDs.

  ## Parameters
  - `target` - SNMP target (host, "host:port", or target map)
  - `oid` - Starting OID for bulk retrieval
  - `opts` - Request options
    - `:timeout` - SNMP PDU timeout in milliseconds (default: 10000)
    - `:max_repetitions` - Maximum number of OIDs to retrieve (default: 30)
    - `:community` - SNMP community string (default: "public")
    - `:version` - SNMP version (must be :v2c) (default: :v2c)
  """
  @spec send_get_bulk_request(target(), oid(), opts()) :: snmp_result()
  def send_get_bulk_request(target, oid, opts \\ []) do
    version = Keyword.get(opts, :version, :v2c)

    case version do
      :v2c ->
        {host, updated_opts} = parse_target_with_port(target, opts)
        oid_parsed = parse_oid_or_fallback(oid)
        snmp_lib_opts = map_options_to_snmp_lib(updated_opts)

        case Manager.get_bulk(host, oid_parsed, snmp_lib_opts) do
          {:ok, results} ->
            {:ok, extract_varbinds(results)}

          {:error, reason} ->
            {:error, reason}
        end

      _ ->
        {:error, :getbulk_requires_v2c}
    end
  end

  @doc """
  Sends an asynchronous SNMP GET request.

  Returns immediately with a reference. The calling process will receive
  a message with the result.

  ## Parameters
  - `target` - SNMP target (host, "host:port", or target map)
  - `oid` - Object identifier (string or list format)
  - `opts` - Request options
    - `:timeout` - SNMP PDU timeout in milliseconds (default: 10000)
    - `:community` - SNMP community string (default: "public")
    - `:version` - SNMP version (:v1, :v2c) (default: :v2c)

  ## Returns
  Reference that will be included in the response message.
  """
  @spec send_get_request_async(target(), oid(), opts()) :: reference()
  def send_get_request_async(target, oid, opts \\ []) do
    caller = self()
    ref = make_ref()

    spawn(fn ->
      result = send_get_request(target, oid, opts)
      send(caller, {ref, result})
    end)

    ref
  end

  @doc """
  Sends an asynchronous SNMP GETBULK request.

  Returns immediately with a reference. The calling process will receive
  a message with the result.

  ## Parameters
  - `target` - SNMP target (host, "host:port", or target map)
  - `oid` - Starting OID for bulk retrieval
  - `opts` - Request options
    - `:timeout` - SNMP PDU timeout in milliseconds (default: 10000)
    - `:max_repetitions` - Maximum number of OIDs to retrieve (default: 30)
    - `:community` - SNMP community string (default: "public")
    - `:version` - SNMP version (must be :v2c) (default: :v2c)

  ## Returns
  Reference that will be included in the response message.
  """
  @spec send_get_bulk_request_async(target(), oid(), opts()) :: reference()
  def send_get_bulk_request_async(target, oid, opts \\ []) do
    caller = self()
    ref = make_ref()

    spawn(fn ->
      result = send_get_bulk_request(target, oid, opts)
      send(caller, {ref, result})
    end)

    ref
  end

  # Private functions for snmp_lib integration

  @spec map_options_to_snmp_lib(opts()) :: list()
  defp map_options_to_snmp_lib(opts) do
    # Map SnmpMgr options to SnmpKit.SnmpLib.Manager options
    mapped = []

    mapped =
      if community = Keyword.get(opts, :community),
        do: [{:community, community} | mapped],
        else: mapped

    mapped =
      if timeout = Keyword.get(opts, :timeout), do: [{:timeout, timeout} | mapped], else: mapped

    mapped =
      if retries = Keyword.get(opts, :retries), do: [{:retries, retries} | mapped], else: mapped

    mapped =
      if version = Keyword.get(opts, :version), do: [{:version, version} | mapped], else: mapped

    mapped = if port = Keyword.get(opts, :port), do: [{:port, port} | mapped], else: mapped

    mapped =
      if max_repetitions = Keyword.get(opts, :max_repetitions),
        do: [{:max_repetitions, max_repetitions} | mapped],
        else: mapped

    mapped =
      if non_repeaters = Keyword.get(opts, :non_repeaters),
        do: [{:non_repeaters, non_repeaters} | mapped],
        else: mapped

    mapped
  end

  @doc """
  Parses and normalizes an OID to internal list format.

  Converts external OID input (string or list) to internal list of integers format.
  This function establishes the API boundary - all external input is converted to
  internal list format here.
  """
  @spec parse_oid(oid()) :: {:ok, list(non_neg_integer())} | {:error, term()}
  def parse_oid(oid) when is_list(oid) do
    # Already a list - validate and return
    case OID.valid_oid?(oid) do
      :ok ->
        {:ok, oid}

      {:error, :empty_oid} ->
        # Empty list fallback
        {:ok, [1, 3]}

      {:error, _reason} ->
        case oid do
          # Single [1] is invalid, use [1,3]
          [1] -> {:ok, [1, 3]}
          _ -> {:error, :invalid_oid_list}
        end
    end
  end

  def parse_oid(oid) when is_binary(oid) do
    case String.trim(oid) do
      "" -> {:ok, [1, 3]}
      trimmed -> resolve_oid_string(trimmed)
    end
  end

  def parse_oid(_), do: {:error, :invalid_oid_input}

  defp resolve_oid_string(trimmed) do
    with {:error, _} <- SnmpKit.SnmpLib.MIB.Registry.resolve_name(trimmed),
         {:error, _} <- parse_numeric_oid(trimmed) do
      MIB.resolve(trimmed)
    end
  end

  defp parse_numeric_oid(oid_string) do
    case OID.string_to_list(oid_string) do
      {:ok, [_ | _] = oid_list} -> {:ok, oid_list}
      {:ok, []} -> {:ok, [1, 3]}
      error -> error
    end
  end

  # Type information must never be inferred - it must be preserved from SNMP responses
  # Removing type inference functions to prevent loss of critical type information
  # Any SNMP operation that does not preserve type information should fail with an error

  # Private helper to check if target contains port specification
  defp target_contains_port?(target) when is_binary(target) do
    cond do
      # RFC 3986 bracket notation: [IPv6]:port
      String.starts_with?(target, "[") and String.contains?(target, "]:") ->
        target |> extract_bracket_port() |> valid_port_string?()

      # Plain IPv6 addresses (contain :: or multiple colons) - no port embedded
      String.contains?(target, "::") ->
        false

      target |> String.graphemes() |> Enum.count(&(&1 == ":")) > 1 ->
        false

      # IPv4 or simple hostname with port
      String.contains?(target, ":") ->
        target |> extract_simple_port() |> valid_port_string?()

      # No colon at all
      true ->
        false
    end
  end

  defp target_contains_port?(_), do: false

  defp extract_bracket_port(target) do
    case String.split(target, "]:", parts: 2) do
      [_ipv6_part, port_part] -> port_part
      _ -> nil
    end
  end

  defp extract_simple_port(target) do
    case String.split(target, ":", parts: 2) do
      [_host_part, port_part] -> port_part
      _ -> nil
    end
  end

  defp valid_port_string?(nil), do: false

  defp valid_port_string?(port_str) do
    case Integer.parse(port_str) do
      {port, ""} when port > 0 and port <= 65_535 -> true
      _ -> false
    end
  end
end
