import AOC

defmodule BingoPlayer do
  use GenServer

  defstruct [:board, :uncalled_numbers, :positions]

  @impl GenServer
  def init(board) do
    numbers =
      board
      |> List.flatten()
      |> MapSet.new()

    {board, positions} =
      for {row, x} <- Enum.with_index(board), {cell, y} <- Enum.with_index(row) do
        {{{x, y}, {cell, false}}, {cell, {x, y}}}
      end
      |> Enum.unzip()

    board = Map.new(board)
    positions = Map.new(positions)

    state = %__MODULE__{
      board: board,
      uncalled_numbers: numbers,
      positions: positions
    }

    {:ok, state}
  end

  def call_number(pid, n) do
    GenServer.call(pid, {:number_called, n})
  end

  @impl GenServer
  def handle_call(
        {:number_called, n},
        _from,
        %__MODULE__{board: board, uncalled_numbers: uncalled_numbers, positions: positions} =
          state
      ) do
    if MapSet.member?(uncalled_numbers, n) do
      uncalled_numbers = MapSet.delete(uncalled_numbers, n)
      board = Map.put(board, Map.get(positions, n), {n, true})

      state = %{state | uncalled_numbers: uncalled_numbers, board: board}

      if is_winning_board?(board) do
        # Prevent winning boards from continuing to play
        uncalled_numbers = MapSet.new()
        state = %{state | uncalled_numbers: uncalled_numbers}
        {:reply, {:win, score_winning_board(board, n)}, state}
      else
        {:reply, :hit, state}
      end
    else
      {:reply, :noop, state}
    end
  end

  defp is_winning_board?(board) do
    Enum.any?(0..4, fn x ->
      Enum.all?(0..4, fn y ->
        Map.get(board, {x, y}) |> then(&elem(&1, 1))
      end)
    end) or
      Enum.any?(0..4, fn y ->
        Enum.all?(0..4, fn x ->
          Map.get(board, {x, y}) |> then(&elem(&1, 1))
        end)
      end)
  end

  defp score_winning_board(board, last_called) do
    sum =
      board
      |> Map.values()
      |> Enum.reject(&elem(&1, 1))
      |> Enum.map(&elem(&1, 0))
      |> Enum.sum()

    sum * last_called
  end
end

aoc 2021, 4 do
  def parse_input() do
    [calls | boards] = input_string() |> String.split("\n\n", trim: true)

    calls = String.split(calls, ",", trim: true) |> Enum.map(&String.to_integer/1)

    boards =
      for board <- boards do
        for row <- String.split(board, "\n") do
          String.split(row, ~r/\W+/, trim: true)
          |> Enum.map(&String.to_integer/1)
        end
      end

    {calls, boards}
  end

  def p1 do
    {calls, boards} = parse_input()

    players =
      for board <- boards do
        {:ok, pid} = GenServer.start_link(BingoPlayer, board)
        pid
      end

    {:halt, score} =
      for call <- calls, player <- players, reduce: :cont do
        {:halt, _} = resp ->
          resp

        :cont ->
          case BingoPlayer.call_number(player, call) do
            {:win, score} -> {:halt, score}
            _ -> :cont
          end
      end

    Enum.each(players, &GenServer.stop/1)

    score
  end

  def p2 do
    {calls, boards} = parse_input()

    players =
      for board <- boards do
        {:ok, pid} = GenServer.start_link(BingoPlayer, board)
        pid
      end

    [last_winning_score | _scores] =
      for call <- calls, player <- players, reduce: [] do
        acc ->
          case BingoPlayer.call_number(player, call) do
            {:win, 0} -> acc
            {:win, score} -> [score | acc]
            _ -> acc
          end
      end

    Enum.each(players, &GenServer.stop/1)

    last_winning_score
  end
end