Advent of Code 2020 in Elixir - Day 16

Ticket Translation

defmodule Aoc2020.Day16 do
  @moduledoc "Ticket Translation"

  def run(), do: part_2()

  def part_1() do
    {rules, [_, nearby_tickets]} = parse_input()
    all_rule_ranges = Enum.map(rules, &elem(&1, 1)) |> List.flatten()

    invalid_values =
      List.flatten(nearby_tickets)
      |> Enum.filter(fn field ->
        !Enum.any?(all_rule_ranges, fn {min, max} -> field >= min && field <= max end)
      end)

    Enum.sum(invalid_values)
  end

  def part_2() do
    {rules, [[my_ticket], nearby_tickets]} = parse_input()
    valid_nearby = filter_valid_tickets(nearby_tickets, rules)
    field_order = map_field_order(rules, valid_nearby)

    departure_fields =
      Enum.filter(field_order, fn {_, v} -> String.starts_with?(v, "departure") end)

    Enum.map(departure_fields, fn {index, _} -> Enum.at(my_ticket, index) end)
    |> Enum.reduce(&*/2)
  end

  def parse_input() do
    File.read!("priv/inputs/2020/day16.txt")
    |> String.trim()
    |> String.split("\n\n")
    |> Enum.map(&String.split(&1, "\n"))
    |> (fn [rules | ticket_groups] ->
          {
            parse_rules(rules),
            Enum.map(ticket_groups, &parse_tickets/1)
          }
        end).()
  end

  def parse_rules(rules) do
    Enum.map(rules, fn rule ->
      [label, rule_set] = String.split(rule, ": ", parts: 2)
      parsed_rules = String.split(rule_set, " or ") |> Enum.map(&parse_range/1)
      {label, parsed_rules}
    end)
  end

  def parse_range(str) do
    String.split(str, "-") |> Enum.map(&String.to_integer/1) |> List.to_tuple()
  end

  def parse_tickets([_ | tickets]) do
    Enum.map(tickets, fn line -> String.split(line, ",") |> Enum.map(&String.to_integer/1) end)
  end

  def filter_valid_tickets(tickets, rules) do
    all_rule_ranges = Enum.map(rules, &elem(&1, 1)) |> List.flatten()

    Enum.filter(tickets, fn ticket ->
      Enum.all?(ticket, fn field ->
        Enum.any?(all_rule_ranges, fn {min, max} -> field >= min && field <= max end)
      end)
    end)
  end

  def map_field_order(rules, tickets) do
    find_possible_fields(rules, tickets)
    |> Enum.sort_by(&length(elem(&1, 1)))
    |> Enum.reduce({MapSet.new(), %{}}, &match_unused_field/2)
    |> elem(1)
  end

  def find_possible_fields(rules, tickets) do
    field_indexes = 0..(length(hd(tickets)) - 1)

    Enum.reduce(field_indexes, [], fn index, acc ->
      tickets_field = Enum.map(tickets, &Enum.at(&1, index))

      matches =
        Enum.filter(rules, fn {_, [{minA, maxA}, {minB, maxB}]} ->
          Enum.all?(tickets_field, fn field ->
            (field >= minA && field <= maxA) || (field >= minB && field <= maxB)
          end)
        end)

      [{index, Enum.map(matches, &elem(&1, 0))} | acc]
    end)
  end

  def match_unused_field({index, possible_fields}, {already_used, field_order}) do
    selected_field =
      MapSet.difference(MapSet.new(possible_fields), already_used) |> MapSet.to_list() |> hd()

    {MapSet.put(already_used, selected_field), Map.put(field_order, index, selected_field)}
  end
end