defmodule Towerops.EctoTypes.MacAddress do
  @moduledoc """
  Custom Ecto type for MAC address fields.

  Provides a rich domain type for MAC addresses with validation,
  normalization, and helper functions. Avoids "primitive obsession" by
  representing MAC addresses as structured data instead of plain strings.

  ## Features

  - Accepts multiple input formats (colon, hyphen, dot-separated)
  - Normalizes to colon-separated lowercase (canonical format)
  - Supports SNMP binary format conversion (6-byte binary)
  - Validates MAC address format
  - Provides helper functions for format detection and conversion

  ## Supported Input Formats

  - Colon-separated: `00:11:22:33:44:55`
  - Hyphen-separated: `00-11-22-33-44-55`
  - Dot-separated (Cisco): `0011.2233.4455`
  - Compact: `001122334455`
  - SNMP binary: `<<0, 17, 34, 51, 68, 85>>`

  ## Examples

      iex> MacAddress.cast("00:11:22:33:44:55")
      {:ok, %MacAddress{address: "00:11:22:33:44:55", binary: <<0, 17, 34, 51, 68, 85>>}}

      iex> MacAddress.cast("00-11-22-33-44-55")
      {:ok, %MacAddress{address: "00:11:22:33:44:55", binary: <<0, 17, 34, 51, 68, 85>>}}

      iex> MacAddress.cast("0011.2233.4455")
      {:ok, %MacAddress{address: "00:11:22:33:44:55", binary: <<0, 17, 34, 51, 68, 85>>}}

      iex> MacAddress.cast("invalid")
      :error

  ## Usage in Schema

      defmodule MyApp.Interface do
        use Ecto.Schema

        schema "interfaces" do
          field :mac_address, Towerops.EctoTypes.MacAddress
        end
      end

  ## Database Storage

  MAC addresses are stored as VARCHAR(17) in the database (sufficient for
  colon-separated format). No database migrations are required when adopting
  this custom type - Ecto handles conversion transparently.
  """
  use Ecto.Type

  @type t :: %__MODULE__{
          address: String.t(),
          binary: binary()
        }

  @derive {Jason.Encoder, only: [:address]}
  defstruct [:address, :binary]

  # Regex patterns for different MAC address formats
  @colon_format ~r/^([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2})$/
  @hyphen_format ~r/^([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})$/
  @dot_format ~r/^([0-9a-fA-F]{4})\.([0-9a-fA-F]{4})\.([0-9a-fA-F]{4})$/
  @compact_format ~r/^([0-9a-fA-F]{12})$/

  @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__{} = mac), do: {:ok, mac}

  # Cast from SNMP binary (6 bytes) - must come before string parsing
  def cast(binary) when is_binary(binary) and byte_size(binary) == 6 do
    # Check if it's a 6-byte binary (not a 6-character string)
    if String.printable?(binary) do
      # It's a short string, try string parsing
      case parse_string(binary) do
        {:ok, normalized, bin} ->
          {:ok, %__MODULE__{address: normalized, binary: bin}}

        :error ->
          :error
      end
    else
      # It's a binary MAC address (SNMP format)
      normalized = binary_to_colon_string(binary)
      {:ok, %__MODULE__{address: normalized, binary: binary}}
    end
  end

  # Cast from string representation
  def cast(value) when is_binary(value) do
    case parse_string(value) do
      {:ok, normalized, binary} ->
        {:ok, %__MODULE__{address: normalized, binary: binary}}

      :error ->
        :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__{address: address}), do: {:ok, address}
  def dump(_), do: :error

  # Public API

  @doc """
  Creates a new MacAddress struct from a string or binary.

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

  ## Examples

      iex> MacAddress.new("00:11:22:33:44:55")
      {:ok, %MacAddress{address: "00:11:22:33:44:55"}}

      iex> MacAddress.new("invalid")
      :error
  """
  @spec new(String.t() | binary()) :: {:ok, t()} | :error
  def new(value), do: cast(value)

  @doc """
  Creates a new MacAddress struct from a string or binary.

  Raises `ArgumentError` on invalid input.

  ## Examples

      iex> MacAddress.new!("00:11:22:33:44:55")
      %MacAddress{address: "00:11:22:33:44:55"}

      iex> MacAddress.new!("invalid")
      ** (ArgumentError) invalid MAC address: "invalid"
  """
  @spec new!(String.t() | binary()) :: t()
  def new!(value) do
    case new(value) do
      {:ok, mac} -> mac
      :error -> raise ArgumentError, "invalid MAC address: #{inspect(value)}"
    end
  end

  @doc """
  Converts a MacAddress struct to its canonical string representation.

  Returns the MAC address in colon-separated lowercase format.

  ## Examples

      iex> mac = MacAddress.new!("00-11-22-33-44-55")
      iex> MacAddress.to_string(mac)
      "00:11:22:33:44:55"
  """
  @spec to_string(t()) :: String.t()
  def to_string(%__MODULE__{address: address}), do: address

  @doc """
  Converts a MacAddress struct to its binary representation.

  Returns the MAC address as a 6-byte binary (SNMP format).

  ## Examples

      iex> mac = MacAddress.new!("00:11:22:33:44:55")
      iex> MacAddress.to_binary(mac)
      <<0, 17, 34, 51, 68, 85>>
  """
  @spec to_binary(t()) :: binary()
  def to_binary(%__MODULE__{binary: binary}), do: binary

  @doc """
  Formats a MacAddress struct in a specific notation.

  Supported formats:
  - `:colon` - "00:11:22:33:44:55" (default)
  - `:hyphen` - "00-11-22-33-44-55"
  - `:dot` - "0011.2233.4455" (Cisco format)
  - `:compact` - "001122334455"

  ## Examples

      iex> mac = MacAddress.new!("00:11:22:33:44:55")
      iex> MacAddress.format(mac, :hyphen)
      "00-11-22-33-44-55"
  """
  @spec format(t(), :colon | :hyphen | :dot | :compact) :: String.t()
  def format(%__MODULE__{binary: binary}, format_type) do
    case format_type do
      :colon -> binary_to_colon_string(binary)
      :hyphen -> binary_to_hyphen_string(binary)
      :dot -> binary_to_dot_string(binary)
      :compact -> binary_to_compact_string(binary)
    end
  end

  # Private helpers

  defp parse_string(""), do: :error

  defp parse_string(value) when is_binary(value) do
    value
    |> String.trim()
    |> try_parse_formats()
  end

  defp try_parse_formats(value) do
    cond do
      Regex.match?(@colon_format, value) -> parse_colon_format(value)
      Regex.match?(@hyphen_format, value) -> parse_hyphen_format(value)
      Regex.match?(@dot_format, value) -> parse_dot_format(value)
      Regex.match?(@compact_format, value) -> parse_compact_format(value)
      true -> :error
    end
  end

  defp parse_colon_format(value) do
    case Regex.run(@colon_format, value) do
      [_, o1, o2, o3, o4, o5, o6] ->
        build_mac_address([o1, o2, o3, o4, o5, o6])

      _ ->
        :error
    end
  end

  defp parse_hyphen_format(value) do
    case Regex.run(@hyphen_format, value) do
      [_, o1, o2, o3, o4, o5, o6] ->
        build_mac_address([o1, o2, o3, o4, o5, o6])

      _ ->
        :error
    end
  end

  defp parse_dot_format(value) do
    case Regex.run(@dot_format, value) do
      [_, g1, g2, g3] ->
        # Split each group into 2-character octets
        <<o1::binary-2, o2::binary-2>> = g1
        <<o3::binary-2, o4::binary-2>> = g2
        <<o5::binary-2, o6::binary-2>> = g3
        build_mac_address([o1, o2, o3, o4, o5, o6])

      _ ->
        :error
    end
  end

  defp parse_compact_format(value) do
    case Regex.run(@compact_format, value) do
      [_, mac] ->
        # Split into 2-character octets
        <<o1::binary-2, o2::binary-2, o3::binary-2, o4::binary-2, o5::binary-2, o6::binary-2>> =
          mac

        build_mac_address([o1, o2, o3, o4, o5, o6])

      _ ->
        :error
    end
  end

  defp build_mac_address(octets) do
    # Convert hex strings to integers
    with {:ok, bytes} <- parse_hex_octets(octets) do
      binary = :erlang.list_to_binary(bytes)
      normalized = binary_to_colon_string(binary)
      {:ok, normalized, binary}
    end
  end

  defp parse_hex_octets(octets) do
    bytes =
      Enum.map(octets, fn octet ->
        {value, ""} = Integer.parse(String.downcase(octet), 16)
        value
      end)

    {:ok, bytes}
  rescue
    _ -> :error
  end

  defp binary_to_colon_string(<<o1, o2, o3, o4, o5, o6>>) do
    Enum.map_join([o1, o2, o3, o4, o5, o6], ":", &format_octet/1)
  end

  defp binary_to_hyphen_string(<<o1, o2, o3, o4, o5, o6>>) do
    Enum.map_join([o1, o2, o3, o4, o5, o6], "-", &format_octet/1)
  end

  defp binary_to_dot_string(<<o1, o2, o3, o4, o5, o6>>) do
    Enum.join(
      [
        format_octet(o1) <> format_octet(o2),
        format_octet(o3) <> format_octet(o4),
        format_octet(o5) <> format_octet(o6)
      ],
      "."
    )
  end

  defp binary_to_compact_string(<<o1, o2, o3, o4, o5, o6>>) do
    Enum.map_join([o1, o2, o3, o4, o5, o6], "", &format_octet/1)
  end

  defp format_octet(byte) do
    byte
    |> Integer.to_string(16)
    |> String.downcase()
    |> String.pad_leading(2, "0")
  end
end
