lib/islands/coord.ex

# ┌────────────────────────────────────────────────────────────────────┐
# │ Based on the book "Functional Web Development" by Lance Halvorsen. │
# └────────────────────────────────────────────────────────────────────┘
defmodule Islands.Coord do
  @moduledoc """
  A coordinates struct and functions for the _Game of Islands_.

  The coordinates struct contains the fields `row` and `col` representing the
  coordinates of a square in the _Game of Islands_.

  ##### Based on the book [Functional Web Development](https://pragprog.com/book/lhelph/functional-web-development-with-elixir-otp-and-phoenix) by Lance Halvorsen.
  """

  alias __MODULE__

  @col_range 1..10
  @row_range 1..10
  @square_range 1..100

  @derive [Poison.Encoder]
  @derive Jason.Encoder
  @enforce_keys [:row, :col]
  defstruct [:row, :col]

  @typedoc "Column number"
  @type col :: 1..10
  @typedoc "Row number"
  @type row :: 1..10
  @typedoc "Square number: (row - 1) * 10 + col"
  @type square :: 1..100
  @typedoc "A coordinates struct for the Game of Islands"
  @type t :: %Coord{row: row, col: col}

  @doc """
  Returns `{:ok, coord}` or `{:error, reason}` if given an invalid `row` or
  `col`.

  ## Examples

      iex> alias Islands.Coord
      iex> Coord.new(10, 10)
      {:ok, %Coord{col: 10, row: 10}}
  """
  @spec new(row, col) :: {:ok, t} | {:error, atom}
  def new(row, col) when row in @row_range and col in @col_range do
    {:ok, %Coord{row: row, col: col}}
  end

  def new(_row, _col), do: {:error, :invalid_coordinates}

  @doc """
  Returns a coordinates struct or raises if given an invalid `row` or `col`.

  ## Examples

      iex> alias Islands.Coord
      iex> Coord.new!(10, 10)
      %Coord{row: 10, col: 10}

      iex> alias Islands.Coord
      iex> Coord.new!(0, 1)
      ** (ArgumentError) cannot create coord, reason: :invalid_coordinates
  """
  @spec new!(row, col) :: t
  def new!(row, col) do
    case new(row, col) do
      {:ok, coord} ->
        coord

      {:error, reason} ->
        raise ArgumentError, "cannot create coord, reason: #{inspect(reason)}"
    end
  end

  @doc """
  Returns `{:ok, coord}` or `{:error, reason}` if given an invalid `square`.

  ## Examples

      iex> alias Islands.Coord
      iex> Coord.new(99)
      {:ok, %Coord{row: 10, col: 9}}
  """
  @spec new(square) :: {:ok, t} | {:error, atom}
  def new(square) when square in @square_range,
    do: rem(square, 10) |> coord(square)

  def new(_square), do: {:error, :invalid_square_number}

  @doc """
  Returns a coordinates struct or raises if given an invalid `square`.

  ## Examples

      iex> alias Islands.Coord
      iex> Coord.new!(99)
      %Coord{row: 10, col: 9}

      iex> alias Islands.Coord
      iex> Coord.new!(101)
      ** (ArgumentError) cannot create coord, reason: :invalid_square_number
  """
  @spec new!(square) :: t
  def new!(square) do
    case new(square) do
      {:ok, coord} ->
        coord

      {:error, reason} ->
        raise ArgumentError, "cannot create coord, reason: #{inspect(reason)}"
    end
  end

  @doc """
  Returns a square number or `{:error, reason}` if given an invalid `coord`.

  ## Examples

      iex> alias Islands.Coord
      iex> {:ok, coord} = Coord.new(2, 9)
      iex> Coord.to_square(coord)
      19
  """
  @spec to_square(Coord.t()) :: square | {:error, atom}
  def to_square(%Coord{row: row, col: col} = _coord), do: (row - 1) * 10 + col
  def to_square(_coord), do: {:error, :invalid_coord_struct}

  @doc """
  Returns "<row> <col>" or `{:error, reason}` if given an invalid `coord`.

  ## Examples

      iex> alias Islands.Coord
      iex> {:ok, coord} = Coord.new(2, 9)
      iex> Coord.to_row_col(coord)
      "2 9"
  """
  @spec to_row_col(Coord.t()) :: String.t() | {:error, atom}
  def to_row_col(%Coord{row: row, col: col} = _coord), do: "#{row} #{col}"
  def to_row_col(_coord), do: {:error, :invalid_coord_struct}

  @doc """
  Compares two coordinates structs based on their square numbers.

  ## Examples
      iex> alias Islands.Coord
      iex> Coord.compare(Coord.new!(4, 7), Coord.new!(5, 7))
      :lt
  """
  @spec compare(t, t) :: :lt | :eq | :gt
  def compare(%Coord{} = coord1, %Coord{} = coord2) do
    case {to_square(coord1), to_square(coord2)} do
      {square1, square2} when square1 > square2 -> :gt
      {square1, square2} when square1 < square2 -> :lt
      _ -> :eq
    end
  end

  ## Private functions

  @spec coord(rem :: 0..9, square) :: {:ok, t}
  defp coord(0, square), do: Coord.new(div(square, 10), 10)
  defp coord(rem, square), do: Coord.new(div(square, 10) + 1, rem)

  ## Helpers

  defimpl String.Chars, for: Coord do
    @spec to_string(Coord.t()) :: String.t()
    def to_string(%Coord{row: row, col: col} = _coord), do: "(#{row}, #{col})"
  end
end