defmodule Towerops.Snmp.Client do
  @moduledoc """
  SNMP client wrapper around snmpkit for device communication.
  Provides clean interface for SNMP get, walk, and get_bulk operations.
  """

  alias Towerops.Profiles.MibCache

  require Logger

  @type connection_opts :: [
          ip: String.t(),
          version: String.t(),
          port: non_neg_integer(),
          transport: String.t(),
          timeout: non_neg_integer(),
          community: String.t(),
          security_name: String.t(),
          security_level: String.t(),
          auth_protocol: String.t(),
          auth_password: String.t(),
          priv_protocol: String.t(),
          priv_password: String.t()
        ]

  @type oid :: String.t() | [non_neg_integer()]
  @type snmp_value :: term()
  @type snmp_result :: {:ok, snmp_value()} | {:error, term()}

  @default_timeout 30_000
  @default_retries 2

  @doc """
  Performs an SNMP GET operation for a single OID.

  ## Examples

      iex> get([ip: "192.168.1.1", community: "public", version: "2c"], "1.3.6.1.2.1.1.1.0")
      {:ok, "Cisco IOS Software..."}

      iex> get([ip: "192.168.1.1", community: "public", version: "2c"], [1, 3, 6, 1, 2, 1, 1, 1, 0])
      {:ok, "Cisco IOS Software..."}
  """
  @spec get(connection_opts(), oid()) :: snmp_result()
  def get(opts, oid) do
    dispatch_snmp(:get, opts, oid, fn -> do_get_with_snmpkit(opts, oid) end)
  end

  # Original get implementation using SnmpKit
  defp do_get_with_snmpkit(opts, oid) do
    target = build_target(opts)
    snmp_opts = build_snmp_opts(opts)

    # Resolve MIB name to numeric OID if needed
    resolved_oid = resolve_mib_name(oid)

    case snmp_adapter().get(target, resolved_oid, snmp_opts) do
      {:ok, value} ->
        {:ok, extract_snmp_value(value)}

      {:error, reason} = error ->
        log_snmp_error("GET", reason, target, oid, opts)
        error
    end
  end

  @doc """
  Performs an SNMP GET operation for multiple OIDs.

  ## Examples

      iex> get_multiple([ip: "192.168.1.1", community: "public", version: "2c"],
      ...>   ["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.3.0"])
      {:ok, ["Cisco IOS Software...", 12345]}
  """
  @spec get_multiple(connection_opts(), [oid()]) :: {:ok, [snmp_value()]} | {:error, term()}
  def get_multiple(opts, oids) when is_list(oids) do
    # Check if custom adapter is specified
    case Keyword.get(opts, :adapter) do
      nil ->
        # Default behavior - use SnmpKit
        do_get_multiple_with_snmpkit(opts, oids)

      adapter ->
        # Custom adapter - delegate and convert to list format
        convert_adapter_result_to_list(adapter.get_multiple(opts, oids), oids)
    end
  end

  # Convert adapter's map result to list format (for backward compatibility)
  defp convert_adapter_result_to_list({:ok, result_map}, oids) do
    values = Enum.map(oids, fn oid -> Map.get(result_map, oid) end)
    {:ok, values}
  end

  defp convert_adapter_result_to_list(error, _oids), do: error

  # Original get_multiple implementation using SnmpKit
  defp do_get_multiple_with_snmpkit(opts, oids) do
    target = build_target(opts)
    snmp_opts = build_snmp_opts(opts)

    # Get each OID and collect results
    results =
      Enum.map(oids, fn oid ->
        # Resolve MIB name to numeric OID if needed
        resolved_oid = resolve_mib_name(oid)

        case snmp_adapter().get(target, resolved_oid, snmp_opts) do
          {:ok, value} -> {:ok, extract_snmp_value(value)}
          error -> error
        end
      end)

    # Check if any failed
    if Enum.any?(results, fn result -> match?({:error, _}, result) end) do
      {:error, :partial_failure}
    else
      values = Enum.map(results, fn {:ok, value} -> value end)
      {:ok, values}
    end
  end

  @doc """
  Performs an SNMP GET-NEXT operation to retrieve the next OID in the tree.
  Returns the next OID and its value after the specified OID.

  ## Examples

      iex> get_next([ip: "192.168.1.1", community: "public", version: "2c"],
      ...>   "1.3.6.1.2.1.1")
      {:ok, %{oid: "1.3.6.1.2.1.1.1.0", value: "Cisco IOS Software..."}}
  """
  @spec get_next(connection_opts(), oid()) ::
          {:ok, %{oid: String.t(), value: snmp_value()}} | {:error, term()}
  def get_next(opts, oid) do
    target = build_target(opts)
    snmp_opts = build_snmp_opts(opts)

    # Resolve MIB name to numeric OID if needed
    resolved_oid = resolve_mib_name(oid)
    Logger.info("SNMP GET-NEXT: #{oid} → #{resolved_oid} for #{target}")

    case snmp_adapter().get_next(target, resolved_oid, snmp_opts) do
      {:ok, %{oid: next_oid, value: value}} ->
        Logger.info("SNMP GET-NEXT: Got OID #{next_oid} = #{inspect(value)}")
        # SnmpKit enriches the varbind, value is already extracted
        {:ok, %{oid: next_oid, value: value}}

      {:error, reason} = error ->
        log_snmp_error("GET-NEXT", reason, target, oid, opts)
        error
    end
  end

  @doc """
  Performs an SNMP WALK operation starting from the given OID.
  Returns all OIDs and values under the specified subtree.

  ## Examples

      iex> walk([ip: "192.168.1.1", community: "public", version: "2c"], "1.3.6.1.2.1.2.2.1.2")
      {:ok, %{
        "1.3.6.1.2.1.2.2.1.2.1" => "Ethernet0",
        "1.3.6.1.2.1.2.2.1.2.2" => "Ethernet1"
      }}
  """
  @spec walk(connection_opts(), oid()) :: {:ok, %{String.t() => snmp_value()}} | {:error, term()}
  def walk(opts, start_oid) do
    dispatch_snmp(:walk, opts, start_oid, fn -> do_walk_with_snmpkit(opts, start_oid) end)
  end

  # Original walk implementation using SnmpKit
  defp do_walk_with_snmpkit(opts, start_oid) do
    target = build_target(opts)
    snmp_opts = build_snmp_opts(opts)

    # Resolve MIB name to numeric OID if needed
    resolved_oid = resolve_mib_name(start_oid)

    case snmp_adapter().walk(target, resolved_oid, snmp_opts) do
      {:ok, results} when is_list(results) ->
        walked_data =
          Map.new(results, fn %{oid: oid, value: value} -> {oid, extract_snmp_value(value)} end)

        {:ok, walked_data}

      {:error, reason} = error ->
        log_snmp_error("WALK", reason, target, start_oid, opts)
        error
    end
  end

  @doc """
  Performs an SNMP GET-BULK operation for efficient retrieval of multiple values.
  Useful for retrieving table data.

  ## Examples

      iex> get_bulk([ip: "192.168.1.1", community: "public", version: "2c"],
      ...>   "1.3.6.1.2.1.2.2.1.2", max_repetitions: 10)
      {:ok, %{"1.3.6.1.2.1.2.2.1.2.1" => "Ethernet0", ...}}
  """
  @spec get_bulk(connection_opts(), oid(), keyword()) ::
          {:ok, %{String.t() => snmp_value()}} | {:error, term()}
  def get_bulk(opts, start_oid, bulk_opts \\ []) do
    target = build_target(opts)
    snmp_opts = build_snmp_opts(opts)
    max_repetitions = Keyword.get(bulk_opts, :max_repetitions, 10)

    snmp_opts = Keyword.put(snmp_opts, :max_repetitions, max_repetitions)

    # Resolve MIB name to numeric OID if needed
    resolved_oid = resolve_mib_name(start_oid)

    case snmp_adapter().get_bulk(target, resolved_oid, snmp_opts) do
      {:ok, results} when is_list(results) ->
        # snmpkit returns a list of maps, convert to OID -> value map
        bulk_data =
          Map.new(results, fn %{oid: oid, value: value} -> {oid, extract_snmp_value(value)} end)

        {:ok, bulk_data}

      {:error, reason} = error ->
        log_snmp_error("GET-BULK", reason, target, start_oid, opts)
        error
    end
  end

  @doc """
  Tests connectivity to an SNMP agent by retrieving sysUpTime.

  ## Examples

      iex> test_connection([ip: "192.168.1.1", community: "public", version: "2c"])
      {:ok, "Connection successful"}

      iex> test_connection([ip: "192.168.1.99", community: "wrong", version: "2c"])
      {:error, :timeout}
  """
  @spec test_connection(connection_opts()) :: {:ok, String.t()} | {:error, term()}
  def test_connection(opts) do
    # get/2 already handles adapter dispatch and disabled check
    case get(opts, "1.3.6.1.2.1.1.3.0") do
      {:ok, _uptime} ->
        {:ok, "Connection successful"}

      {:error, reason} = error ->
        Logger.warning("SNMP connection test failed: #{inspect(reason)}")
        error
    end
  end

  @doc """
  Returns true if Phoenix-side SNMP operations are disabled.

  When true, all direct SNMP operations from Phoenix (get, walk, etc.) are blocked.
  SNMP polling should be handled by connected agents instead.
  Always returns false in test environment.
  """
  @spec phoenix_snmp_disabled() :: boolean()
  def phoenix_snmp_disabled, do: do_phoenix_snmp_disabled(Application.get_env(:towerops, :env))

  defp do_phoenix_snmp_disabled(:test), do: false
  defp do_phoenix_snmp_disabled(_env), do: Application.get_env(:towerops, :disable_phoenix_snmp, true)

  # Dispatches SNMP operations: custom adapter → disabled check → default implementation
  defp dispatch_snmp(operation, opts, oid, default_fn) do
    case Keyword.get(opts, :adapter) do
      nil ->
        if phoenix_snmp_disabled() do
          log_disabled_call(Atom.to_string(operation), opts, oid)
          {:error, :phoenix_snmp_disabled}
        else
          default_fn.()
        end

      adapter when operation == :walk ->
        opts |> adapter.walk(oid) |> convert_adapter_walk_to_map()

      adapter ->
        adapter.get(opts, oid)
    end
  end

  # Convert adapter's list result to map format (for backward compatibility)
  defp convert_adapter_walk_to_map({:ok, results}) when is_list(results) do
    {:ok, Map.new(results, fn {oid, value} -> {oid, value} end)}
  end

  defp convert_adapter_walk_to_map(error), do: error

  # Shared SNMP error logging with pattern-matched clauses for known vs unknown errors
  defp log_snmp_error(operation, reason, target, oid, _opts)
       when reason in [:no_such_object, :no_such_instance, :no_such_name, :end_of_mib_view, :not_found] do
    Logger.debug("SNMP #{operation}: #{reason} for #{target} at #{inspect(oid)}")
  end

  defp log_snmp_error(operation, reason, target, oid, opts) do
    timeout = Keyword.get(opts, :timeout, @default_timeout)
    version = Keyword.fetch!(opts, :version)

    Logger.warning(
      "SNMP #{operation} failed for #{target} (v#{version}, timeout: #{timeout}ms) OID #{inspect(oid)}: #{inspect(reason)}"
    )
  end

  defp log_disabled_call(operation, opts, oid_or_param) do
    ip = Keyword.get(opts, :ip, "unknown")
    version = Keyword.get(opts, :version, "unknown")

    Logger.info("""
    [Phoenix SNMP Disabled] Would have executed SNMP operation:
      Operation: #{operation}, Target: #{ip}, Version: #{version}, OID/Param: #{inspect(oid_or_param)}
    """)
  end

  defp snmp_adapter do
    Application.get_env(:towerops, :snmp_adapter, SnmpKit)
  end

  defp build_target(opts) do
    Keyword.fetch!(opts, :ip)
  end

  defp build_snmp_opts(opts) do
    version = parse_version(Keyword.fetch!(opts, :version))
    port = Keyword.get(opts, :port, 161)
    timeout = Keyword.get(opts, :timeout, @default_timeout)

    base_opts = [
      version: version,
      port: port,
      timeout: timeout,
      retries: @default_retries
    ]

    # Add version-specific authentication options
    case version do
      :v3 ->
        base_opts ++
          [
            security_name: Keyword.fetch!(opts, :security_name),
            security_level: parse_security_level(Keyword.fetch!(opts, :security_level)),
            auth_protocol: parse_auth_proto(Keyword.get(opts, :auth_protocol)),
            auth_password: Keyword.get(opts, :auth_password),
            priv_protocol: parse_priv_proto(Keyword.get(opts, :priv_protocol)),
            priv_password: Keyword.get(opts, :priv_password)
          ]

      _ ->
        base_opts ++ [community: Keyword.fetch!(opts, :community)]
    end
  end

  defp parse_version("1"), do: :v1
  defp parse_version("2c"), do: :v2c
  defp parse_version("3"), do: :v3
  defp parse_version(:v1), do: :v1
  defp parse_version(:v2c), do: :v2c
  defp parse_version(:v3), do: :v3
  defp parse_version(_other), do: :v2c

  defp parse_security_level("noAuthNoPriv"), do: :no_auth_no_priv
  defp parse_security_level("authNoPriv"), do: :auth_no_priv
  defp parse_security_level("authPriv"), do: :auth_priv
  defp parse_security_level(_), do: :no_auth_no_priv

  defp parse_auth_proto("MD5"), do: :md5
  defp parse_auth_proto("SHA"), do: :sha
  defp parse_auth_proto("SHA-224"), do: :sha224
  defp parse_auth_proto("SHA-256"), do: :sha256
  defp parse_auth_proto("SHA-384"), do: :sha384
  defp parse_auth_proto("SHA-512"), do: :sha512
  defp parse_auth_proto(_), do: :sha256

  defp parse_priv_proto("DES"), do: :des
  defp parse_priv_proto("AES"), do: :aes
  defp parse_priv_proto("AES-192"), do: :aes192
  defp parse_priv_proto("AES-256"), do: :aes256
  defp parse_priv_proto("AES-256-C"), do: :aes256c
  defp parse_priv_proto(_), do: :aes

  @doc """
  Resolve MIB name to numeric OID using SnmpKit.
  Handles MODULE-NAME::objectName format by stripping module prefix.
  Returns numeric OID as dotted string, or original value if resolution fails.
  """
  @spec resolve_mib_name(oid()) :: String.t()
  def resolve_mib_name(oid) when is_binary(oid) do
    # Check if it's already a numeric OID
    if String.starts_with?(oid, ".") or String.match?(oid, ~r/^\d+(\.\d+)*$/) do
      oid
    else
      resolve_symbolic_oid(oid)
    end
  end

  def resolve_mib_name(oid), do: oid

  defp resolve_symbolic_oid(oid) do
    # Use MibCache for all MIB name resolution (includes timeout protection)
    # MibCache handles both full "MODULE-NAME::objectName" and plain "objectName" formats
    MibCache.lookup(oid)
  end

  # Extract value from snmpkit's typed responses
  defp extract_snmp_value({:octet_string, value}), do: value
  defp extract_snmp_value({:integer, value}), do: value
  defp extract_snmp_value({:counter32, value}), do: value
  defp extract_snmp_value({:counter64, value}), do: value
  defp extract_snmp_value({:gauge32, value}), do: value
  defp extract_snmp_value({:timeticks, value}), do: value
  defp extract_snmp_value({:ip_address, value}), do: value
  defp extract_snmp_value({:object_identifier, value}), do: value
  defp extract_snmp_value({:opaque, value}), do: value
  # Handle map responses from snmpkit (with :value key)
  defp extract_snmp_value(%{value: value}), do: value
  # Fallback for unknown types
  defp extract_snmp_value(value), do: value
end
