lib/puzzle.ex

defmodule Wordler.Puzzle do
  @moduledoc """
  Functions for solving Wordler puzzles.
  """
  @default_source Wordler.Sources.Dict

  @type t() :: %__MODULE__{
          word_list: [binary()],
          solution_list: [binary()],
          guesses: [binary()],
          guessed_letters: MapSet.t(integer()),
          rejected_letters: MapSet.t(integer()),
          solution_members: MapSet.t(integer()),
          solution_positions: [nil | integer()],
          solution_antipositions: [MapSet.t(integer())],
          solution: binary()
        }

  defstruct word_list: [],
            solution_list: [],
            guesses: [],
            guessed_letters: MapSet.new(),
            rejected_letters: MapSet.new(),
            solution_members: MapSet.new(),
            solution_positions: [nil, nil, nil, nil, nil],
            solution_antipositions: [
              MapSet.new(),
              MapSet.new(),
              MapSet.new(),
              MapSet.new(),
              MapSet.new()
            ],
            solution: nil

  @doc """
  Creates a new blank puzzle.

  ## Examples

      iex> Wordler.Puzzle.new()
      %Wordler.Puzzle{word_list: [], ...}
  """
  def new(options \\ []) do
    source_module = Keyword.get(options, :source, @default_source)

    word_list =
      apply(source_module, :five_letter_words, [])
      |> Enum.sort(fn word1, word2 ->
        Wordler.Sources.WordBank.frequency_score(word1) >
          Wordler.Sources.WordBank.frequency_score(word2)
      end)

    solution_list =
      apply(source_module, :five_letter_words, [[{:duplicates, true}]])
      |> Enum.sort(fn word1, word2 ->
        Wordler.Sources.WordBank.frequency_score(word1) >
          Wordler.Sources.WordBank.frequency_score(word2)
      end)

    %__MODULE__{
      word_list: word_list,
      solution_list: solution_list
    }
  end

  @doc """
  Generate the next guess for a given puzzle.

  Returns a binary string guess. Will return an "exploratory guess" if possible,
  covering new unguessed letters, or fall back to a "solving guess" if no exploratory guesses are possible.

  ## Examples

      iex> Wordler.Puzzle.new()
      |> Wordler.Puzzle.new_guess()
      "irate"
  """
  @spec next_guess(t()) :: binary()
  def next_guess(puzzle) do
    # @todo do something proportionate with solving vs exploratory guesses:
    # potential % reduction of solution list vs confidence of solve
    case exploratory_guesses(puzzle) do
      [] -> List.first(solving_guesses(puzzle))
      guesses -> List.first(guesses)
    end
  end

  @doc """
  Handle the results of a guess.

  Returns a new Puzzle with updated constraints and reduced word counts.

  ## Examples

      iex> puzzle = Wordler.Puzzle.new()
      %Wordler.Puzzle{word_list: [], ...}
      iex> length(puzzle.solution_list)
      4594
      iex> guess = Wordler.Puzzle.new_guess(puzzle)
      "irate"
      iex> results = [true, "r", false, false, false]
      [true, "r", false, false, false]
      iex> puzzle = Wordler.Puzzle.handle_guess(puzzle, guess, results)
      %Wordler.Puzzle{word_list: [], ...}
      iex> length(puzzle.solution_list)
      95
  """
  @spec handle_guess(t(), binary(), [true | false | binary()]) :: Wordler.Puzzle.t()
  def handle_guess(puzzle, guess, letter_results) do
    {updated_puzzle, _} =
      Enum.zip(String.to_charlist(guess), letter_results)
      |> Enum.reduce({puzzle, 0}, fn {letter, result}, {puzzle_acc, i} ->
        case result do
          result when is_binary(result) ->
            {add_position(puzzle_acc, letter, i), i + 1}

          true ->
            {
              puzzle_acc
              |> add_member(letter)
              |> add_antiposition(letter, i),
              i + 1
            }

          false ->
            {add_reject(puzzle_acc, letter), i + 1}
        end
      end)

    updated_puzzle
    |> add_guess(guess)
    |> update_search_space()
  end

  defp exploratory_guesses(puzzle) do
    # guesses having as little in common as possible with guessed_letters as possible
    reject_set = MapSet.difference(puzzle.solution_members, MapSet.new(puzzle.solution_positions))

    puzzle.word_list
    |> Enum.reject(fn word ->
      for(<<char <- word>>, do: char in reject_set)
      |> Enum.any?()
    end)
  end

  defp solving_guesses(puzzle) do
    # guesses having as much in common with solution_members as possible
    puzzle.solution_list
    |> Enum.map(fn word ->
      common_members =
        for <<char <- word>>, reduce: 0 do
          acc ->
            cond do
              char in puzzle.solution_members -> acc + 1
              true -> acc
            end
        end

      {word, common_members}
    end)
    |> Enum.sort(fn {w1, cm1}, {w2, cm2} ->
      if cm1 > cm2 do
        true
      else
        Wordler.Sources.WordBank.frequency_score(w1) >
          Wordler.Sources.WordBank.frequency_score(w2)
      end
    end)
    |> Enum.map(&elem(&1, 0))
  end

  defp update_search_space(%__MODULE__{} = puzzle) do
    %__MODULE__{
      puzzle
      | word_list: filter_list(puzzle, :word_list),
        solution_list: filter_list(puzzle, :solution_list)
    }
  end

  defp filter_list(puzzle, list_type) do
    Map.get(puzzle, list_type)
    |> Enum.filter(fn word ->
      Enum.zip([
        String.to_charlist(word),
        puzzle.solution_positions,
        puzzle.solution_antipositions
      ])
      |> Enum.reduce_while(true, fn {char, at_position, not_at_position}, _ ->
        cond do
          at_position && at_position != char ->
            IO.puts(
              "rejecting #{word} due to position of #{<<char>>} conflicting with known #{<<at_position>>} at that position"
            )

            {:halt, false}

          char in not_at_position ->
            IO.puts(
              "rejecting #{word} due to position of #{<<char>>} known not to be at that position"
            )

            {:halt, false}

          char in puzzle.rejected_letters ->
            IO.puts("rejecting #{word} due presence of #{<<char>>}")
            {:halt, false}

          true ->
            {:cont, true}
        end
      end)
    end)
  end

  defp add_guess(%__MODULE__{guesses: guesses, guessed_letters: guessed_letters} = puzzle, guess) do
    new_guesses = Enum.reverse([guess | Enum.reverse(guesses)])

    new_guessed_letters =
      guess
      |> String.to_charlist()
      |> Enum.reduce(guessed_letters, fn char, guessed -> MapSet.put(guessed, char) end)

    %__MODULE__{puzzle | guesses: new_guesses, guessed_letters: new_guessed_letters}
  end

  defp add_reject(%__MODULE__{rejected_letters: rejected_letters} = puzzle, rejected_letter) do
    IO.puts("rejecting #{<<rejected_letter>>}")

    %__MODULE__{
      puzzle
      | rejected_letters: MapSet.put(rejected_letters, rejected_letter)
    }
  end

  defp add_member(%__MODULE__{solution_members: solution_members} = puzzle, member_letter) do
    IO.puts("adding #{<<member_letter>>}")

    %__MODULE__{
      puzzle
      | solution_members: MapSet.put(solution_members, member_letter)
    }
  end

  defp add_position(
         %__MODULE__{solution_positions: solution_positions} = puzzle,
         member_letter,
         member_position
       ) do
    IO.puts("adding #{<<member_letter>>} at position #{member_position}")

    %__MODULE__{
      puzzle
      | solution_positions: List.replace_at(solution_positions, member_position, member_letter)
    }
    |> add_member(member_letter)
  end

  defp add_antiposition(
         %__MODULE__{solution_antipositions: solution_antipositions} = puzzle,
         member_letter,
         member_antiposition
       ) do
    IO.puts("adding #{<<member_letter>>} in antiposition #{member_antiposition}")

    %__MODULE__{
      puzzle
      | solution_antipositions:
          List.update_at(solution_antipositions, member_antiposition, fn ex ->
            MapSet.put(ex, member_letter)
          end)
    }
    |> add_member(member_letter)
  end
end