defmodule Towerops.EctoTypes.SnmpOid do
  @moduledoc """
  Custom Ecto type for SNMP OID (Object Identifier) fields.

  Provides a rich domain type for SNMP OIDs with validation, normalization,
  and resolution. Avoids "primitive obsession" by representing OIDs as
  structured data instead of plain strings.

  ## Features

  - Validates OID format (both numeric and named)
  - Normalizes OIDs to dotted-decimal notation
  - Supports named OID resolution via SnmpKit
  - Handles partial OIDs and wildcards
  - Provides helper functions for OID manipulation

  ## Supported Formats

  - Dotted-decimal: `.1.3.6.1.2.1.1.1.0`
  - Numeric (no leading dot): `1.3.6.1.2.1.1.1.0`
  - Named OID: `sysDescr.0` or `SNMPv2-MIB::sysDescr.0`
  - Mixed format: `1.3.6.1.2.1.system.1.0`

  ## Examples

      iex> SnmpOid.cast(".1.3.6.1.2.1.1.1.0")
      {:ok, %SnmpOid{oid: ".1.3.6.1.2.1.1.1.0", parts: [1, 3, 6, 1, 2, 1, 1, 1, 0]}}

      iex> SnmpOid.cast("1.3.6.1.2.1.1.1.0")
      {:ok, %SnmpOid{oid: ".1.3.6.1.2.1.1.1.0", parts: [1, 3, 6, 1, 2, 1, 1, 1, 0]}}

      iex> SnmpOid.cast("sysDescr.0")
      {:ok, %SnmpOid{oid: ".1.3.6.1.2.1.1.1.0", named: "sysDescr.0", parts: [1, 3, 6, 1, 2, 1, 1, 1, 0]}}

      iex> SnmpOid.cast("invalid..oid")
      :error

  ## Usage in Schema

      defmodule MyApp.Sensor do
        use Ecto.Schema

        schema "sensors" do
          field :sensor_oid, Towerops.EctoTypes.SnmpOid
        end
      end

  ## Database Storage

  OIDs are stored as VARCHAR(255) in the database in dotted-decimal format.
  No database migrations are required when adopting this custom type - Ecto
  handles conversion transparently.

  ## OID Resolution

  Named OIDs are automatically resolved to numeric format using SnmpKit.
  If resolution fails, the OID is stored as-is without error.

      # Automatic resolution
      {:ok, oid} = SnmpOid.cast("sysDescr.0")
      oid.oid  # => ".1.3.6.1.2.1.1.1.0"
      oid.named  # => "sysDescr.0"
  """
  use Ecto.Type

  @type t :: %__MODULE__{
          oid: String.t(),
          named: String.t() | nil,
          parts: [non_neg_integer()]
        }

  @derive {Jason.Encoder, only: [:oid, :named]}
  defstruct [:oid, :named, :parts]

  # OID validation patterns
  @numeric_oid_pattern ~r/^\.?([0-9]+)(\.([0-9]+))*$/
  @named_oid_pattern ~r/^[a-zA-Z][a-zA-Z0-9_-]*(::[a-zA-Z][a-zA-Z0-9_-]*)?((\.[a-zA-Z0-9_-]+)|(\.[0-9]+))*$/

  @impl Ecto.Type
  def type, do: :string

  @impl Ecto.Type
  @spec cast(term()) :: {:ok, t() | nil} | :error
  def cast(nil), do: {:ok, nil}

  # Idempotence - accept structs that are already cast
  def cast(%__MODULE__{} = oid), do: {:ok, oid}

  # Cast from string representation
  def cast(value) when is_binary(value) do
    case parse_oid(value) do
      {:ok, normalized_oid, parts} ->
        # Try to resolve if it's a named OID
        named = if is_named_oid?(value), do: value
        {:ok, %__MODULE__{oid: normalized_oid, named: named, parts: parts}}

      :error ->
        :error
    end
  end

  # Cast from integer list (OID parts)
  def cast(parts) when is_list(parts) do
    if Enum.all?(parts, &is_integer/1) and Enum.all?(parts, &(&1 >= 0)) do
      normalized = "." <> Enum.join(parts, ".")
      {:ok, %__MODULE__{oid: normalized, named: nil, parts: parts}}
    else
      :error
    end
  end

  def cast(_), do: :error

  @impl Ecto.Type
  @spec load(term()) :: {:ok, t() | nil} | :error
  def load(nil), do: {:ok, nil}
  def load(value) when is_binary(value), do: cast(value)
  def load(_), do: :error

  @impl Ecto.Type
  @spec dump(term()) :: {:ok, String.t() | nil} | :error
  def dump(nil), do: {:ok, nil}
  def dump(%__MODULE__{oid: oid}), do: {:ok, oid}
  def dump(_), do: :error

  # Public API

  @doc """
  Creates a new SnmpOid struct from a string or list.

  Returns `{:ok, %SnmpOid{}}` on success or `:error` on failure.

  ## Examples

      iex> SnmpOid.new(".1.3.6.1.2.1.1.1.0")
      {:ok, %SnmpOid{oid: ".1.3.6.1.2.1.1.1.0"}}

      iex> SnmpOid.new([1, 3, 6, 1, 2, 1, 1, 1, 0])
      {:ok, %SnmpOid{oid: ".1.3.6.1.2.1.1.1.0"}}

      iex> SnmpOid.new("invalid..oid")
      :error
  """
  @spec new(String.t() | [non_neg_integer()]) :: {:ok, t()} | :error
  def new(value), do: cast(value)

  @doc """
  Creates a new SnmpOid struct from a string or list.

  Raises `ArgumentError` on invalid input.

  ## Examples

      iex> SnmpOid.new!(".1.3.6.1.2.1.1.1.0")
      %SnmpOid{oid: ".1.3.6.1.2.1.1.1.0"}

      iex> SnmpOid.new!("invalid..oid")
      ** (ArgumentError) invalid SNMP OID: "invalid..oid"
  """
  @spec new!(String.t() | [non_neg_integer()]) :: t()
  def new!(value) do
    case new(value) do
      {:ok, oid} -> oid
      :error -> raise ArgumentError, "invalid SNMP OID: #{inspect(value)}"
    end
  end

  @doc """
  Converts an SnmpOid struct to its string representation.

  Returns the OID in dotted-decimal notation with leading dot.

  ## Examples

      iex> oid = SnmpOid.new!("1.3.6.1.2.1.1.1.0")
      iex> SnmpOid.to_string(oid)
      ".1.3.6.1.2.1.1.1.0"
  """
  @spec to_string(t()) :: String.t()
  def to_string(%__MODULE__{oid: oid}), do: oid

  @doc """
  Converts an SnmpOid struct to a list of integers.

  ## Examples

      iex> oid = SnmpOid.new!(".1.3.6.1.2.1.1.1.0")
      iex> SnmpOid.to_list(oid)
      [1, 3, 6, 1, 2, 1, 1, 1, 0]
  """
  @spec to_list(t()) :: [non_neg_integer()]
  def to_list(%__MODULE__{parts: parts}), do: parts

  @doc """
  Returns the parent OID by removing the last part.

  ## Examples

      iex> oid = SnmpOid.new!(".1.3.6.1.2.1.1.1.0")
      iex> parent = SnmpOid.parent(oid)
      iex> parent.oid
      ".1.3.6.1.2.1.1.1"
  """
  @spec parent(t()) :: t()
  def parent(%__MODULE__{parts: parts}) when length(parts) > 1 do
    parent_parts = Enum.slice(parts, 0..-2//1)
    parent_oid = "." <> Enum.join(parent_parts, ".")
    %__MODULE__{oid: parent_oid, named: nil, parts: parent_parts}
  end

  def parent(%__MODULE__{} = oid), do: oid

  @doc """
  Returns true if this OID is a child of the given parent OID.

  ## Examples

      iex> oid = SnmpOid.new!(".1.3.6.1.2.1.1.1.0")
      iex> parent = SnmpOid.new!(".1.3.6.1.2.1.1")
      iex> SnmpOid.child_of?(oid, parent)
      true
  """
  @spec child_of?(t(), t()) :: boolean()
  def child_of?(%__MODULE__{parts: child_parts}, %__MODULE__{parts: parent_parts}) do
    length(child_parts) > length(parent_parts) and
      List.starts_with?(child_parts, parent_parts)
  end

  @doc """
  Appends parts to an OID.

  ## Examples

      iex> oid = SnmpOid.new!(".1.3.6.1.2.1.1")
      iex> extended = SnmpOid.append(oid, [1, 0])
      iex> extended.oid
      ".1.3.6.1.2.1.1.1.0"
  """
  @spec append(t(), [non_neg_integer()]) :: t()
  def append(%__MODULE__{parts: parts}, additional_parts) when is_list(additional_parts) do
    new_parts = parts ++ additional_parts
    new_oid = "." <> Enum.join(new_parts, ".")
    %__MODULE__{oid: new_oid, named: nil, parts: new_parts}
  end

  @doc """
  Attempts to resolve a named OID to its numeric form.

  Uses SnmpKit for resolution. Returns the original OID if resolution fails.

  ## Examples

      iex> SnmpOid.resolve("sysDescr.0")
      {:ok, ".1.3.6.1.2.1.1.1.0"}

      iex> SnmpOid.resolve(".1.3.6.1.2.1.1.1.0")
      {:ok, ".1.3.6.1.2.1.1.1.0"}
  """
  @spec resolve(String.t()) :: {:ok, String.t()} | {:error, term()}
  def resolve(oid_string) when is_binary(oid_string) do
    # Try SnmpKit resolution
    case SnmpKit.resolve(oid_string) do
      {:ok, resolved} ->
        {:ok, ensure_leading_dot(resolved)}

      {:error, _} = error ->
        resolve_numeric_oid(oid_string, error)
    end
  end

  defp resolve_numeric_oid(oid_string, error) do
    if Regex.match?(@numeric_oid_pattern, oid_string) do
      {:ok, ensure_leading_dot(oid_string)}
    else
      error
    end
  end

  defp ensure_leading_dot(oid_string) do
    if String.starts_with?(oid_string, "."), do: oid_string, else: "." <> oid_string
  end

  # Private helpers

  defp parse_oid(""), do: :error

  defp parse_oid(value) when is_binary(value) do
    value = String.trim(value)

    cond do
      Regex.match?(@numeric_oid_pattern, value) ->
        parse_numeric_oid(value)

      Regex.match?(@named_oid_pattern, value) ->
        # Try to resolve named OID to numeric
        case resolve(value) do
          {:ok, resolved} -> parse_numeric_oid(resolved)
          {:error, _} -> :error
        end

      true ->
        :error
    end
  end

  defp parse_numeric_oid(value) do
    # Strip leading dot if present
    value = String.trim_leading(value, ".")

    # Split into parts and validate
    case String.split(value, ".") do
      [""] ->
        :error

      parts ->
        try do
          int_parts = Enum.map(parts, &String.to_integer/1)

          if Enum.all?(int_parts, &(&1 >= 0)) do
            normalized = "." <> Enum.join(int_parts, ".")
            {:ok, normalized, int_parts}
          else
            :error
          end
        rescue
          _ -> :error
        end
    end
  end

  defp is_named_oid?(value) when is_binary(value) do
    Regex.match?(@named_oid_pattern, value) and
      not Regex.match?(@numeric_oid_pattern, value)
  end
end
