defmodule Crutches.String do
  alias Crutches.Option

  import String,
    only: [
      replace: 3,
      slice: 2,
      trim: 1
    ]

  @moduledoc ~s"""
  Convenience functions for strings.

  This module provides several convenience functions operating on strings.
  Simply call any function (with any options if applicable) to make use of it.
  """

  # Access

  @doc ~S"""
  Gives a substring of `string` from the given `position`.

  If `position` is positive, counts from the start of the string.
  If `position` is negative, count from the end of the string.

  ## Examples
      iex> String.from("hello", 0)
      "hello"

      iex> String.from("hello", 3)
      "lo"

      iex> String.from("hello", -2)
      "lo"

      iex> String.from("hello", -7)
      ""

  You can mix it with +to+ method and do fun things like:

      iex> "hello"
      iex> |> String.from(0)
      iex> |> String.to(-1)
      "hello"

      iex> "hello"
      iex> |> String.from(1)
      iex> |> String.to(-2)
      "ell"

      iex> "a"
      iex> |> String.from(1)
      iex> |> String.to(1500)
      ""

      iex> "elixir"
      iex> |> String.from(-10)
      iex> |> String.to(-7)
      ""
  """
  @spec from(String.t(), integer) :: String.t()
  def from(string, position) when position >= 0 do
    slice(string, position..(String.length(string) - 1))
  end

  def from(string, position) when position < 0 do
    new_position = String.length(string) + position

    case new_position < 0 do
      true -> ""
      false -> slice(string, new_position..(String.length(string) - 1))
    end
  end

  @doc ~S"""
  Returns a substring from the beginning of the string to the given position.
  If the position is negative, it is counted from the end of the string.

  ## Examples
      iex> String.to("hello", 0)
      "h"

      iex> String.to("hello", 3)
      "hell"

      iex> String.to("hello", -2)
      "hell"

  You can mix it with +from+ method and do fun things like:
      iex> "hello"
      iex> |> String.from(0)
      iex> |> String.to(-1)
      "hello"

      iex> "hello"
      iex> |> String.from(1)
      iex> |> String.to(-2)
      "ell"
  """
  @spec to(String.t(), integer) :: String.t()
  def to(string, length) when length >= 0 do
    slice(string, 0..length)
  end

  def to(string, length) when length < 0 do
    slice(string, 0..(String.length(string) + length))
  end

  # Filters

  @doc ~S"""
  Returns the string, first removing all whitespace on both ends of
  the string, and then changing remaining consecutive whitespace
  groups into one space each.

  ## Examples
      iex> str = "A multi line
      iex> string"
      iex> String.squish(str)
      "A multi line string"

      iex> str = " foo   bar    \n   \t   boo"
      iex> String.squish(str)
      "foo bar boo"
  """
  @spec squish(String.t()) :: String.t()
  def squish(string) do
    string |> replace(~r/[[:space:]]+/, " ") |> trim
  end

  @doc ~S"""
  Remove all occurrences of `pattern` from `string`.

  Can also take a list of `patterns`.

  ## Examples
      iex> String.remove("foo bar test", " test")
      "foo bar"

      iex> String.remove("foo bar test", ~r/foo /)
      "bar test"

      iex> String.remove("foo bar test", [~r/foo /, " test"])
      "bar"
  """
  @spec remove(String.t(), String.t() | Regex.t() | list(any)) :: String.t()
  def remove(string, patterns) when is_list(patterns) do
    patterns |> Enum.reduce(string, &remove(&2, &1))
  end

  def remove(string, pattern) do
    replace(string, pattern, "")
  end

  @doc ~S"""
  Capitalizes every word in a string. Similar to ActiveSupport's #titleize.

    iex> String.titlecase("the truth is rarely pure and never simple.")
    "The Truth Is Rarely Pure And Never Simple."
    iex> String.titlecase("THE TRUTH IS RARELY PURE AND NEVER SIMPLE.")
    "The Truth Is Rarely Pure And Never Simple."
    iex> String.titlecase("the truth is rarely pure and NEVER simple.")
    "The Truth Is Rarely Pure And Never Simple."
  """
  @spec titlecase(String.t()) :: String.t()
  def titlecase(string) when is_binary(string) do
    string
    |> String.split(" ")
    |> Enum.map(&String.capitalize/1)
    |> Enum.join(" ")
  end

  @doc ~S"""
  Truncates a given `text` after a given `length` if `text` is longer than `length`:

  Truncates a given text after a given `len`gth if text is longer than `len`th.
  The last characters will be replaced with the `:omission` (defaults to “...”) for a total length not exceeding `len`.

  Pass a `:separator` to truncate text at a natural break (the first occurence of that separator before the provided length).

  ## Examples

      iex> String.truncate("Once upon a time in a world far far away", 27)
      "Once upon a time in a wo..."

      iex> String.truncate("Once upon a time in a world far far away", 27, separator: " ")
      "Once upon a time in a..."

      iex> String.truncate("Once upon a time in a world far far away", 27, separator: ~r/\s/)
      "Once upon a time in a..."

      iex> String.truncate("Once upon a time in a world far far away", 35, separator: "far ")
      "Once upon a time in a world far..."

      iex> String.truncate("And they found that many people were sleeping better.", 25, omission: "... (continued)")
      "And they f... (continued)"

      iex> String.truncate("Supercalifragilisticexpialidocious", 24, separator: ~r/\s/)
      "Supercalifragilistice..."
  """
  @truncate [
    valid: ~w(separator omission)a,
    defaults: [
      separator: nil,
      omission: "..."
    ]
  ]
  def truncate(string, len, opts \\ [])

  def truncate(nil, _, _), do: nil

  def truncate(string, len, opts) when is_binary(string) and is_integer(len) do
    opts = Option.combine!(opts, @truncate)

    if String.length(string) > len do
      length_with_room = len - String.length(opts[:omission])
      do_truncate(string, length_with_room, opts[:separator], opts[:omission])
    else
      string
    end
  end

  defp do_truncate(string, length, sep, omission) when is_nil(sep) do
    String.slice(string, 0, length) <> omission
  end

  defp do_truncate(string, length, sep, omission) when is_binary(sep) do
    sep_size = String.length(sep)

    chunk_indexes =
      string
      |> String.codepoints()
      |> Enum.take(length)
      |> Enum.with_index()
      |> Enum.chunk_every(sep_size)
      |> Enum.reverse()
      |> Enum.find(fn chars ->
        str = chars |> Enum.map(&elem(&1, 0)) |> Enum.join("")
        str == sep
      end)

    {_, index} = if chunk_indexes, do: List.last(chunk_indexes), else: {nil, length}

    do_truncate(string, index, nil, omission)
  end

  defp do_truncate(string, length, sep, omission) do
    case Regex.scan(sep, string, return: :index) do
      [_ | _] = captures ->
        [{index, _}] = captures |> Enum.reverse() |> Enum.find(fn [{i, _}] -> i <= length end)
        do_truncate(string, index, nil, omission)

      [] ->
        do_truncate(string, length, nil, omission)
    end
  end
end
