defmodule Optimus.ColumnFormatter do
  @type align :: :left | :center | :right
  @type column_spec :: pos_integer | {pos_integer, align}

  @spec format([column_spec], [String.t()]) :: {:ok, [[String.t()]]} | {:error, String.t()}
  def format(column_specs, strings) do
    with :ok <- validate(column_specs, strings), do: {:ok, format_valid(column_specs, strings)}
  end

  defp format_valid(column_specs, strings) do
    column_specs
    |> Enum.zip(strings)
    |> Enum.map(fn {spec, string} -> {spec, split(string, width(spec))} end)
    |> to_lines
  end

  defp validate(column_specs, strings) do
    with :ok <- validate_lengths(column_specs, strings),
         :ok <- validate_strings(strings),
         :ok <- validate_specs(column_specs),
         do: :ok
  end

  defp validate_lengths(column_specs, strings) when is_list(column_specs) and is_list(strings) do
    if length(column_specs) == length(strings) do
      :ok
    else
      {:error, "arguments should have equal lengths"}
    end
  end

  defp validate_lengths(_, _), do: {:error, "arguments should be lists"}

  defp validate_strings([]), do: :ok

  defp validate_strings([string | strings]) do
    if is_binary(string) do
      validate_strings(strings)
    else
      {:error, "second argument is expected to be a list of strings"}
    end
  end

  defp validate_specs([]), do: :ok

  defp validate_specs([spec | specs]) do
    case spec do
      val when is_integer(val) and val > 0 ->
        validate_specs(specs)

      {val, align}
      when is_integer(val) and val > 0 and (align == :left or align == :center or align == :right) ->
        validate_specs(specs)

      _ ->
        {:error,
         "first argument is expected to be a list of width specs, where width spec is a positive integer or a tuple {width, align} where width is a positive integer and align is one of: :left, :center or :right"}
    end
  end

  defp split(string, max_width) do
    string
    |> String.graphemes()
    |> split_graphemes(max_width, [])
  end

  defp to_lines(split_strings, converted \\ []) do
    {heads, rests} = heads_and_rests(split_strings, [], [])

    if have_nonempty?(heads) do
      line = to_line(heads, [])
      to_lines(rests, [line | converted])
    else
      Enum.reverse(converted)
    end
  end

  defp to_line([], converted) do
    converted
    |> Enum.reverse()
  end

  defp to_line([head | heads], converted) do
    {spec, line_part} = head
    padded_line_part = pad(spec, line_part)
    to_line(heads, [padded_line_part | converted])
  end

  defp pad(spec, nil), do: spec |> width |> spaces

  defp pad(spec, {string_width, string}) do
    case align(spec) do
      :left ->
        padding_string = spaces(width(spec) - string_width)
        string <> padding_string

      :right ->
        padding_string = spaces(width(spec) - string_width)
        padding_string <> string

      :center ->
        padding_count = width(spec) - string_width
        left = div(padding_count, 2)
        right = padding_count - left
        spaces(left) <> string <> spaces(right)
    end
  end

  defp spaces(len) when is_integer(len) and len < 0, do: ""
  defp spaces(len) when is_integer(len) and len >= 0, do: String.duplicate(" ", len)

  defp heads_and_rests([], heads, rests), do: {Enum.reverse(heads), Enum.reverse(rests)}

  defp heads_and_rests([{spec, [string_head | string_rest]} | rest], heads, rests) do
    heads_and_rests(rest, [{spec, string_head} | heads], [{spec, string_rest} | rests])
  end

  defp heads_and_rests([{spec, []} | rest], heads, rests) do
    heads_and_rests(rest, [{spec, nil} | heads], [{spec, []} | rests])
  end

  defp width({w, _}), do: w
  defp width(w) when is_integer(w), do: w

  defp align({_, a}), do: a
  defp align(w) when is_integer(w), do: :left

  def split_graphemes([], _, already_split), do: Enum.reverse(already_split)

  def split_graphemes(graphemes, max_width, already_split) do
    {max_graphemes, rest} = Enum.split(graphemes, max_width)

    [head, tail] =
      if space_first?(rest) do
        [max_graphemes, []]
      else
        split_by_last_space_grapheme(max_graphemes)
      end

    {new_rest, current} =
      case head do
        # failed to split
        [] ->
          {rest, tail}

        _ ->
          {tail ++ rest, head}
      end

    formatted_current = current |> trim |> join_and_keep_width

    split_graphemes(new_rest, max_width, [formatted_current | already_split])
  end

  defp split_by_last_space_grapheme(list) do
    list
    |> Enum.reverse()
    |> Enum.split_while(&not_space?/1)
    |> Tuple.to_list()
    |> Enum.map(&Enum.reverse/1)
    |> Enum.reverse()
  end

  defp trim(graphemes) do
    graphemes
    |> Enum.drop_while(&space?/1)
    |> Enum.reverse()
    |> Enum.drop_while(&space?/1)
    |> Enum.reverse()
  end

  def join_and_keep_width(graphemes) do
    {length(graphemes), Enum.join(graphemes)}
  end

  @space ~r/\A\s+\z/

  defp space?(grapheme) do
    grapheme =~ @space
  end

  defp not_space?(grapheme) do
    !space?(grapheme)
  end

  defp space_first?([grapheme | _]), do: space?(grapheme)
  defp space_first?([]), do: true

  defp have_nonempty?(heads) do
    Enum.any?(heads, fn {_spec, head} -> head != nil end)
  end
end
