defmodule Text.Language do
  @moduledoc """
  A module to support natural language
  detection.

  The primary models are implementations
  derived from [Language Identification from Text
  Using N-gram Based Cumulative Frequency Addition](http://www.csis.pace.edu/~ctappert/srd2004/paper12.pdf)

  """

  @known_classifiers [
    Text.Language.Classifier.NaiveBayesian,
    Text.Language.Classifier.CummulativeFrequency,
    Text.Language.Classifier.RankOrder
  ]

  @default_max_demand 20

  @doc """
  Detect the natural language of a given text.

  ## Arguments

  * `text` is a binary text from which
    the language is detected.

  * `options` is a keyword list of
    options.

  ## Options

  * `:corpus` is a module encapsulating a body of
    text in one or more natural languages.A corpus
    module implements the `Text.Corpus` behaviour.
    The default is `Text.Corpus.Udhr` which is implemented by the
    [text_corpus_udhr](https://hex.pm/packages/text_corpus_udhr)
    package. This package must be installed as a dependency in
    order for this default to be used.

  * `:classifier` is the module used to detect the language.
    The default is `Text.Language.Classifier.NaiveBayesian`.
    Other classifiers are `Text.Language.Classifier.RankOrder`,
    `Text.Classifier.CummulativeFrequency` and
    `Text.Language.Classifier.Spearman`. Any module that
    implements the `Text.Language.Classifier` behaviour
    may be used.

  * `:vocabulary` is the vocabulary to be used. The
    default is `hd(corpus.known_vocabularies())`. Available
    vocabularies are returned from `corpus.known_vocabularies/0`.

  * `:only` is a list of languages to be used
    as candidates for the language of `text`. The
    default is `corpus.known_languages/0`
    which is all the lanuages known to a given
    corpus.

  * `:max_demand` is used to determine the batch size
    for `Flow.from_enumerable/1`. The default is
    `#{@default_max_demand}`.

  ## Returns

  * A list of `2-tuples` in order of confidence with
    the first element being the BCP-47 language code
    and the second element being the score as determined
    by the requested classifier. The score has no meaning
    except to order the results by confidence level.

  """
  @spec detect(String.t, Keyword.t) :: {:ok, Text.language} | {:error, {module(), String.t}}

  def detect(text, options \\ []) when is_binary(text) do
    case classify(text, options) do
      {:error, _} = error -> error
      [{language, _} | _rest] -> {:ok, language}
    end
  end

  @doc """
  Classify the natural language of a given text.

  ## Arguments

  * `text` is a binary text from which
    the language is detected.

  * `options` is a keyword list of
    options.

  ## Options

  * `:corpus` is a module encapsulating a body of
    text in one or more natural languages.A corpus
    module implements the `Text.Corpus` behaviour.
    The default is `Text.Corpus.Udhr` which is implemented by the
    [text_corpus_udhr](https://hex.pm/packages/text_corpus_udhr)
    package. This package must be installed as a dependency in
    order for this default to be used.

  * `:classifier` is the module used to detect the language.
    The default is `Text.Language.Classifier.NaiveBayesian`.
    Other classifiers are `Text.Language.Classifier.RankOrder`,
    `Text.Classifier.CummulativeFrequency` and
    `Text.Language.Classifier.Spearman`. Any module that
    implements the `Text.Language.Classifier` behaviour
    may be used.

  * `:vocabulary` is the vocabulary to be used. The
    default is `hd(corpus.known_vocabularies())`. Available
    vocabularies are returned from `corpus.known_vocabularies/0`.

  * `:only` is a list of languages to be used
    as candidates for the language of `text`. The
    default is `corpus.known_languages/0`
    which is all the lanuages known to a given
    corpus.

  * `:max_demand` is used to determine the batch size
    for `Flow.from_enumerable/1`. The default is
    `#{@default_max_demand}`.

  ## Returns

  * A list of `2-tuples` in order of confidence with
    the first element being the BCP-47 language code
    and the second element being the score as determined
    by the requested classifier. The score has no meaning
    except to order the results by confidence level.

  """
  @spec classify(String.t, Keyword.t) ::
    Text.Language.Classifier.frequency_list() | {:error, {module(), String.t}}

  def classify(text, options \\ []) when is_binary(text) do
    corpus = Keyword.get(options, :corpus, Text.Corpus.Udhr)
    classifier = Keyword.get(options, :classifier, Text.Language.Classifier.NaiveBayesian)
    vocabulary = Keyword.get(options, :vocabulary)
    languages = Keyword.get(options, :only, corpus.known_languages())
    max_demand = Keyword.get(options, :max_demand, @default_max_demand)

    with {:ok, corpus} <- validate(:corpus, corpus),
         {:ok, classifier} <- validate(:classifier, classifier),
         {:ok, vocabulary} <- validate(:vocabulary, corpus, vocabulary),
         {:ok, languages} <- validate(:only, corpus, languages) do
      ensure_vocabulary_loaded!(vocabulary)

      text_ngrams =
        text
        |> corpus.normalize_text
        |> vocabulary.calculate_ngrams

      languages
      |> Flow.from_enumerable(max_demand: max_demand)
      |> Flow.map(&classifier.score_one_language(&1, text_ngrams, vocabulary))
      |> Enum.to_list
      |> classifier.order_scores()
    end
  end

  @doc """
  Returns a list of the known
  classifiers that can be applied as
  a `:classifer` option to `Text.Language.detect/2`

  """
  @spec known_classifiers :: [Text.Language.Classifier.t, ...]
  def known_classifiers do
    @known_classifiers
  end

  @doc """
  Function to remove text elements that
  interfer with language detection.

  Each corpus has a callback `normalize_text/1`
  that is applied when training the
  classifier and when detecting language
  from natural text. If desired, the corpus
  can delegate to this function.

  ## Argument

  * `text` is any `String.t`

  ## Returns

  * A normalized `String.t`

  """
  @spec normalize_text(String.t) :: String.t
  def normalize_text(text) do
    text
    # Downcase
    |> String.downcase()
    # Make sure that there is letter before punctuation
    |> String.replace(~r/\.\s*/u, "_")
    # Discard all digits
    |> String.replace(~r/[0-9]/u, "")
    # Discard all punctuation except for apostrophe
    |> String.replace(~r/[&\/\\#,+()$~%.":*?<>{}]/u, "")
    # Remove duplicate spaces
    |> String.replace(~r/\s+/u, " ")
  end

  defp ensure_vocabulary_loaded!(vocabulary) do
    :persistent_term.get({vocabulary, :languages}, nil) || vocabulary.load_vocabulary!
  end

  defp validate(:corpus, corpus) when is_atom(corpus) do
    if corpus_module?(corpus) do
      {:ok, corpus}
    else
      {:error, {ArgumentError, "Unknown corpus #{inspect(corpus)}"}}
    end
  end

  defp validate(:classifier, classifier) when classifier in @known_classifiers do
    {:ok, classifier}
  end

  defp validate(:classifier, classifier) do
    {:error,
     {ArgumentError,
      "Unknown classifier #{inspect(classifier)}. " <>
        "Known classifiers are #{inspect(@known_classifiers)}."}}
  end

  defp validate(:vocabulary, corpus, nil) do
    known_vocabularies = corpus.known_vocabularies
    validate(:vocabulary, corpus, hd(known_vocabularies))
  end

  defp validate(:vocabulary, corpus, vocabulary) do
    known_vocabularies = corpus.known_vocabularies

    if vocabulary in corpus.known_vocabularies do
      {:ok, vocabulary}
    else
      {:error,
        {ArgumentError,
          "Unknown vocabulary #{inspect(vocabulary)}. " <>
          "Known vocabularies are #{inspect(known_vocabularies)}."
      }}
    end
  end

  defp validate(:only, corpus, languages) do
    known_languages = corpus.known_languages
    unknown_languages = Enum.filter(languages, &(&1 not in known_languages))

    if unknown_languages == [] do
      {:ok, languages}
    else
      {:error,
       {ArgumentError,
        "Unknown languages #{inspect(unknown_languages)}. " <>
          "Known languages are #{inspect(known_languages)}."}}
    end
  end

  @doc false
  def corpus_module?(corpus) do
    Code.ensure_loaded?(corpus) && function_exported?(corpus, :known_vocabularies, 0)
  end
end
