defmodule Towerops.EctoTypes.IpAddress do
  @moduledoc """
  Custom Ecto type for IP address fields.

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

  ## Features

  - Validates IP address format using `:inet.parse_address/1`
  - Supports both IPv4 and IPv6 addresses
  - Provides version detection (`:ipv4` or `:ipv6`)
  - Converts between string and tuple representations
  - Handles edge cases (nil, IPv6 zone IDs, empty strings)
  - Rejects CIDR notation (use separate `prefix_length` field instead)

  ## Examples

      iex> IpAddress.cast("192.168.1.1")
      {:ok, %IpAddress{address: "192.168.1.1", version: :ipv4, tuple: {192, 168, 1, 1}}}

      iex> IpAddress.cast("2001:db8::1")
      {:ok, %IpAddress{address: "2001:db8::1", version: :ipv6, tuple: {8193, 3512, 0, 0, 0, 0, 0, 1}}}

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

  ## Usage in Schema

      defmodule MyApp.Device do
        use Ecto.Schema

        schema "devices" do
          field :ip_address, Towerops.EctoTypes.IpAddress
        end
      end

  ## Database Storage

  IP addresses are stored as VARCHAR(45) in the database (sufficient for
  IPv6 addresses). No database migrations are required when adopting this
  custom type - Ecto handles conversion transparently.
  """
  use Ecto.Type

  @type ip_tuple :: :inet.ip4_address() | :inet.ip6_address()
  @type version :: :ipv4 | :ipv6
  @type t :: %__MODULE__{
          address: String.t(),
          version: version(),
          tuple: ip_tuple()
        }

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

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

  # Cast from string representation
  def cast(value) when is_binary(value) do
    case parse_string(value) do
      {:ok, address, ip_tuple} ->
        version = detect_version(ip_tuple)
        {:ok, %__MODULE__{address: address, version: version, tuple: ip_tuple}}

      :error ->
        :error
    end
  end

  # Cast from Erlang IP tuple
  def cast(ip_tuple) when is_tuple(ip_tuple) do
    case tuple_to_string(ip_tuple) do
      {:ok, address} ->
        version = detect_version(ip_tuple)
        {:ok, %__MODULE__{address: address, version: version, tuple: ip_tuple}}

      :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 IPAddress struct from a string or tuple.

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

  ## Examples

      iex> IpAddress.new("192.168.1.1")
      {:ok, %IpAddress{address: "192.168.1.1", version: :ipv4}}

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

  @doc """
  Creates a new IPAddress struct from a string or tuple.

  Raises `ArgumentError` on invalid input.

  ## Examples

      iex> IpAddress.new!("192.168.1.1")
      %IpAddress{address: "192.168.1.1", version: :ipv4}

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

  @doc """
  Returns true if the IP address is IPv4.

  ## Examples

      iex> ip = IpAddress.new!("192.168.1.1")
      iex> IpAddress.ipv4?(ip)
      true
  """
  @spec ipv4?(t()) :: boolean()
  def ipv4?(%__MODULE__{version: :ipv4}), do: true
  def ipv4?(_), do: false

  @doc """
  Returns true if the IP address is IPv6.

  ## Examples

      iex> ip = IpAddress.new!("2001:db8::1")
      iex> IpAddress.ipv6?(ip)
      true
  """
  @spec ipv6?(t()) :: boolean()
  def ipv6?(%__MODULE__{version: :ipv6}), do: true
  def ipv6?(_), do: false

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

  ## Examples

      iex> ip = IpAddress.new!("192.168.1.1")
      iex> IpAddress.to_string(ip)
      "192.168.1.1"
  """
  @spec to_string(t()) :: String.t()
  def to_string(%__MODULE__{address: address}), do: address

  @doc """
  Converts an IPAddress struct to its tuple representation.

  ## Examples

      iex> ip = IpAddress.new!("192.168.1.1")
      iex> IpAddress.to_tuple(ip)
      {192, 168, 1, 1}
  """
  @spec to_tuple(t()) :: ip_tuple()
  def to_tuple(%__MODULE__{tuple: tuple}), do: tuple

  # Private helpers

  defp parse_string(""), do: :error

  defp parse_string(value) when is_binary(value) do
    # Strip IPv6 zone ID if present (e.g., "fe80::1%eth0" -> "fe80::1")
    cleaned = strip_zone_id(value)

    # Reject CIDR notation (should use separate prefix_length field)
    if String.contains?(cleaned, "/") do
      :error
    else
      # Parse using Erlang's inet module
      case cleaned |> String.to_charlist() |> :inet.parse_address() do
        {:ok, ip_tuple} -> {:ok, cleaned, ip_tuple}
        {:error, _} -> :error
      end
    end
  end

  defp strip_zone_id(value) do
    case String.split(value, "%", parts: 2) do
      [ip, _zone] -> ip
      [ip] -> ip
    end
  end

  defp detect_version(ip_tuple) when tuple_size(ip_tuple) == 4, do: :ipv4
  defp detect_version(ip_tuple) when tuple_size(ip_tuple) == 8, do: :ipv6

  defp tuple_to_string(ip_tuple) when is_tuple(ip_tuple) do
    case :inet.ntoa(ip_tuple) do
      {:error, _} -> :error
      charlist -> {:ok, List.to_string(charlist)}
    end
  end
end
