lib/masker.ex

defmodule Masker do
  @moduledoc """
  Tool for finding a 2d pattern in a 2d list.

  iex> pattern = [[:O, :X, :O], [:O, :X, :O], [:X, :X, :X]]
  ...> array2d = [
  ...>   [1, 4, 2, 4, 5],
  ...>   [1, 5, 1, 5, 4],
  ...>   [4, 5, 1, 5, 8],
  ...>   [8, 1, 1, 1, 4],
  ...>   [5, 1, 2, 4, 1]
  ...> ]
  ...> Masker.has_pattern?(pattern, array2d)
  """

  @doc """
  Find pattern and return coordinates of each match.
  """
  @spec has_pattern?(list, list) :: :none | {:ok, [...]}
  def has_pattern?([], _), do: :none
  def has_pattern?(_, []), do: :none
  def has_pattern?(pattern, array2d) do
    %{height: p_height, width: p_width} =
      height_width(pattern)
    %{height: a_height, width: a_width} =
      height_width(array2d)

    if elements_length(pattern) <= elements_length(array2d) &&
      p_height <= a_height && p_width <= a_width do

      pattern_coordinates =
        pattern
        |> keys_coordinates
        |> Map.values
        |> Enum.sort
      pattern_coordinates_size = length(pattern_coordinates)

      results = 0..(a_height - p_height)
      |> Enum.map(fn row ->
        0..(a_width - p_width)
        |> Enum.map(fn col ->
          fragment_coordinates =
            crop(array2d, p_height, p_width, row, col)
            |> keys_coordinates
            |> Map.values
          if pattern_coordinates_size == length(fragment_coordinates) &&
            Enum.sort(fragment_coordinates) == pattern_coordinates do
            %{row: row, col: col}
          end
        end)
      end)
      |> List.flatten
      |> Enum.reject(&is_nil/1)
      case results do
        [] -> :none
        coordinates -> {:ok, coordinates}
      end
    else
      :none
    end
  end

  @doc """
  Crop a 2d list
  """
  @spec crop(list, integer, integer, integer, integer) :: list
  def crop([], _, _, _, _), do: []
  def crop(_, height, width, _, _) when height <= 0 or width <= 0, do: []
  def crop(array2d, height, width, row, col) do
    {row, col} = {negative_to_zero(row), negative_to_zero(col)}
    %{
      height: scoped_height,
      width: scoped_width
    } =
      max_coord_within_bounds(array2d, height, width, row, col)

    row..(scoped_height - 1)
    |> Enum.map(fn row_index ->
      col..(scoped_width - 1)
      |> Enum.map(fn col_index ->
        array2d
        |> Enum.at(row_index)
        |> Enum.at(col_index)
      end)
    end)
  end

  defp max_coord_within_bounds(array2d, height, width, row, col) do
    %{height: ar_height, width: ar_width} = height_width(array2d)
    scoped_height = row + height
    scoped_width = col + width
    %{
      height: (if scoped_height <= ar_height, do: scoped_height, else: ar_height),
      width: (if scoped_width <= ar_width, do: scoped_width, else: ar_width)
    }
  end

  defp negative_to_zero(value), do: if value < 0, do: 0, else: value

  @spec keys_coordinates(list) :: %{any => list}
  def keys_coordinates(pattern) do
    pattern
    |> Enum.with_index
    |> Enum.reduce(%{}, fn {row, row_i}, acc_all ->
      row
      |> Enum.with_index
      |> Enum.reduce(acc_all, fn {element, col_i}, acc ->
        element_instr = case acc[element] do
          nil -> [[row_i, col_i]]
          existing -> [[row_i, col_i] | existing]
        end
        Map.put(acc, element, element_instr)
      end)
    end)
  end

  defp height_width(array2d) do
    %{
      height: length(array2d),
      width: length(Enum.at(array2d, 1))
    }
  end

  defp elements(matrix) do
    matrix
    |> Enum.flat_map(fn li -> Enum.uniq(li) end)
    |> Enum.uniq
  end

  defp elements_length(array2d) do
    array2d
    |> elements
    |> length
  end
end