# pylint: disable=unspecified-encoding
"""Solutions for 2022 Advent of Code puzzles.

https://adventofcode.com/2022
"""
import argparse
import functools
import itertools
import json
from queue import LifoQueue
import re


def day_1_part_1():
    """Solve Day 1 Part 1 calories puzzle."""
    with open('day_1.txt') as in_file:
        elves = in_file.read().split('\n\n')
    elves = (elf.strip().split('\n') for elf in elves)
    rations = (sum(int(food) for food in elf) for elf in elves)
    return max(rations)


def day_1_part_2():
    """Solve Day 1 Part 2 calories puzzle."""
    with open('day_1.txt') as in_file:
        elves = in_file.read().split('\n\n')
    elves = (elf.strip().split('\n') for elf in elves)
    rations = [sum(int(food) for food in elf) for elf in elves]
    rations.sort()
    return sum(rations[-3:])


def day_2_part_1():
    """Solve Day 2 Part 1 rock-paper-scissors puzzle."""
    rock, paper, scissors = 1, 2, 3
    loss, draw, win = 0, 3, 6
    scores = {
        'A X': rock + draw,
        'A Y': paper + win,
        'A Z': scissors + loss,
        'B X': rock + loss,
        'B Y': paper + draw,
        'B Z': scissors + win,
        'C X': rock + win,
        'C Y': paper + loss,
        'C Z': scissors + draw,
    }
    with open('day_2.txt') as in_file:
        total = sum(scores[move.strip()] for move in in_file)
    return total


def day_2_part_2():
    """Solve Day 2 Part 2 rock-paper-scissors puzzle."""
    rock, paper, scissors = 1, 2, 3
    loss, draw, win = 0, 3, 6
    scores = {
        'A X': loss + scissors,
        'A Y': draw + rock,
        'A Z': win + paper,
        'B X': loss + rock,
        'B Y': draw + paper,
        'B Z': win + scissors,
        'C X': loss + paper,
        'C Y': draw + scissors,
        'C Z': win + rock,
    }
    with open('day_2.txt') as in_file:
        total = sum(scores[move.strip()] for move in in_file)
    return total


def day_3_part_1():
    """Solve Day 3 Part 1 rucksack puzzle."""
    with open('day_3.txt') as in_file:
        rucksacks = in_file.read().splitlines()
    total = 0
    for rucksack in rucksacks:
        middle = len(rucksack) // 2
        item = (set(rucksack[:middle]) & set(rucksack[middle:])).pop()
        total += ord(item) - (96 if ord(item) > 96 else 38)
    return total


def day_3_part_2():
    """Solve Day 3 Part 2 rucksack puzzle."""
    with open('day_3.txt') as in_file:
        rucksacks = in_file.read().splitlines()
    total = 0
    groups = (rucksacks[i:i+3] for i in range(0, len(rucksacks), 3))
    for group in groups:
        item = (set(group[0]) & set(group[1]) & set(group[2])).pop()
        total += ord(item) - (96 if ord(item) > 96 else 38)
    return total


def day_4_part_1():
    """Solve Day 4 Part 1 section overlap puzzle."""
    with open('day_4.txt') as in_file:
        assignments = in_file.read().splitlines()
    overlaps = 0
    for pair in assignments:
        sections = (
            [int(i) for i in section.split('-')]
            for section in pair.split(',')
        )
        outer, inner = sorted(sections, key=lambda x: x[0])
        if outer[0] == inner[0] or outer[1] >= inner[1]:
            overlaps += 1
    return overlaps


def day_4_part_2():
    """Solve Day 4 Part 2 section overlap puzzle."""
    with open('day_4.txt') as in_file:
        assignments = in_file.read().splitlines()
    overlaps = 0
    for pair in assignments:
        sections = (
            [int(i) for i in section.split('-')]
            for section in pair.split(',')
        )
        lower, higher = sorted(sections, key=lambda x: x[0])
        if higher[0] <= lower[1]:
            overlaps += 1
    return overlaps


def day_5_part_1():
    """Solve Day 5 Part 1 crane stacking puzzle."""
    with open('day_5.txt') as in_file:
        initial_stack, steps = in_file.read().split('\n\n')
    # Parse header (containing initial stack list)
    stacks = {}
    header, *initial_stack = initial_stack.splitlines()[::-1]
    for i, char in enumerate(header):
        if char.isdigit():
            stacks[char] = LifoQueue()
            for row in initial_stack:
                if len(row) >= i and row[i] != ' ':
                    stacks[char].put(row[i])
    # Parse steps and apply onto header
    pattern = re.compile(r'move (\d+) from (\d+) to (\d+)')
    for step in steps.splitlines():
        count, src, dst = pattern.match(step).groups()
        for _ in range(int(count)):
            crate = stacks[src].get()
            stacks[dst].put(crate)
    return ''.join(stack.get() for stack in stacks.values())


def day_5_part_2():
    """Solve Day 5 Part 2 crane stacking puzzle."""
    with open('day_5.txt') as in_file:
        initial_stack, steps = in_file.read().split('\n\n')
    # Parse header (containing initial stack list)
    stacks = {}
    header, *initial_stack = initial_stack.splitlines()[::-1]
    for i, char in enumerate(header):
        if char.isdigit():
            stacks[char] = LifoQueue()
            for row in initial_stack:
                if len(row) >= i and row[i] != ' ':
                    stacks[char].put(row[i])
    # Parse steps and apply onto header
    pattern = re.compile(r'move (\d+) from (\d+) to (\d+)')
    for step in steps.splitlines():
        count, src, dst = pattern.match(step).groups()
        crates = [stacks[src].get() for _ in range(int(count))]
        for crate in crates[::-1]:
            stacks[dst].put(crate)
    return ''.join(stack.get() for stack in stacks.values())


def day_6_part_1():
    """Solve Day 6 Part 1 datastream packet puzzle."""
    with open('day_6.txt') as in_file:
        datastream = in_file.read()
    for i, _ in enumerate(datastream, start=4):
        if len(set(datastream[i-4:i])) == 4:
            return i
    raise ValueError("No start-of-packet marker found.")


def day_6_part_2():
    """Solve Day 6 Part 2 datastream packet puzzle."""
    with open('day_6.txt') as in_file:
        datastream = in_file.read()
    for i, _ in enumerate(datastream, start=14):
        if len(set(datastream[i-14:i])) == 14:
            return i
    raise ValueError("No start-of-message marker found.")


def day_7_part_1():
    """Solve Day 7 Part 1 directory map puzzle."""
    dirs, files = {}, {}
    cwd = ''
    reading_files = False
    with open('day_7.txt') as in_file:
        for line in in_file:
            line = line.strip()
            # cd
            if match := re.match(r'\$ cd (.+)', line):
                reading_files = False
                command = match[1]
                if command == '/':
                    cwd = ''
                elif command == '..':
                    cwd = cwd.rsplit('/', 1)[0]
                else:
                    cwd = '/'.join([cwd, command])
                    dirs[cwd] = 0
            # ls
            elif line == '$ ls':
                reading_files = True
            elif reading_files:
                size, name = line.split()
                path = '/'.join([cwd, name])
                if size == 'dir':
                    dirs[path] = 0
                else:
                    files[path] = int(size)
    total = 0
    for directory in dirs:
        dir_size = sum(size for path, size in files.items()
                       if path.startswith(directory))
        if dir_size < 100000:
            total += dir_size
    return total


def day_7_part_2():
    """Solve Day 7 Part 2 directory map puzzle."""
    dirs, files = {}, {}
    cwd = ''
    reading_files = False
    with open('day_7.txt') as in_file:
        for line in in_file:
            line = line.strip()
            # cd
            if match := re.match(r'\$ cd (.+)', line):
                reading_files = False
                command = match[1]
                if command == '/':
                    cwd = ''
                elif command == '..':
                    cwd = cwd.rsplit('/', 1)[0]
                else:
                    cwd = '/'.join([cwd, command])
                    dirs[cwd] = 0
            # ls
            elif line == '$ ls':
                reading_files = True
            elif reading_files:
                size, name = line.split()
                path = '/'.join([cwd, name])
                if size == 'dir':
                    dirs[path] = 0
                else:
                    files[path] = int(size)
    size_needed = sum(size for size in files.values()) - 40_000_000
    size_to_free = 70_000_000
    for directory in dirs:
        dir_size = sum(size for path, size in files.items()
                       if path.startswith(directory))
        if size_needed < dir_size < size_to_free:
            size_to_free = dir_size
    return size_to_free


def day_8_part_1():
    """Solve Day 8 Part 1 tree visibility puzzle."""
    with open('day_8.txt') as in_file:
        trees = [[int(tree) for tree in row] for row in in_file.read().splitlines()]
    size = len(trees)
    visible = [[False] * size for _ in range(size)]

    def traverse_tree_line(tree_line):
        """Update `visible` traversal table with visible trees."""
        height = -1
        for row, column, tree in tree_line:
            if tree > height:
                height = tree
                visible[row][column] = True

    for row, tree_line in enumerate(trees):
        # Left to right
        traverse_tree_line((row, column, tree) for column, tree
                           in enumerate(tree_line))
        # Right to left
        traverse_tree_line((row, column, tree) for column, tree
                           in list(enumerate(tree_line))[::-1])
    for column, tree_line in enumerate(zip(*trees)):
        # Top to bottom
        traverse_tree_line((row, column, tree) for row, tree
                           in enumerate(tree_line))
        # Bottom to top
        traverse_tree_line((row, column, tree) for row, tree
                           in list(enumerate(tree_line))[::-1])
    return sum(sum(row) for row in visible)


def day_8_part_2():
    """Solve Day 8 Part 2 tree visibility puzzle."""
    with open('day_8.txt') as in_file:
        trees = [[int(tree) for tree in row] for row in in_file.read().splitlines()]
    size = len(trees)
    max_score = 0
    for row, tree_line in enumerate(trees):
        for column, tree in enumerate(tree_line):
            i = row - 1
            while i > 0 and trees[i][column] < tree:
                i -= 1
            score = -(i - row)
            i = row + 1
            while i < size - 1 and trees[i][column] < tree:
                i += 1
            score *= i - row
            j = column - 1
            while j > 0 and tree_line[j] < tree:
                j -= 1
            score *= -(j - column)
            j = column + 1
            while j < size - 1 and tree_line[j] < tree:
                j += 1
            score *= j - column
            max_score = max(max_score, score)
    return max_score


def day_9_part_1():
    """Solve Day 9 Part 1 rope bridge puzzle."""
    head, tail = (0, 0), (0, 0)
    history = {tail}
    head_moves = {
        'U': (0, 1),
        'D': (0, -1),
        'R': (1, 0),
        'L': (-1, 0),
    }
    tail_moves = {
        (2, 0): (1, 0),
        (-2, 0): (-1, 0),
        (0, 2): (0, 1),
        (0, -2): (0, -1),
        (2, 1): (1, 1),
        (1, 2): (1, 1),
        (2, -1): (1, -1),
        (1, -2): (1, -1),
        (-2, 1): (-1, 1),
        (-1, 2): (-1, 1),
        (-2, -1): (-1, -1),
        (-1, -2): (-1, -1),
    }
    with open('day_9.txt') as in_file:
        for command in in_file:
            direction, steps = command.strip().split()
            for _ in range(int(steps)):
                head = tuple(h + m for h, m in zip(head, head_moves[direction]))
                diff = tuple(h - t for h, t in zip(head, tail))
                move = tail_moves.get(diff, (0, 0))
                tail = tuple(t + m for t, m in zip(tail, move))
                history.add(tail)
    return len(history)


def day_9_part_2():
    """Solve Day 9 Part 2 rope bridge puzzle."""
    head, tails = (0, 0), [(0, 0) for _ in range(9)]
    history = {tails[-1]}
    head_moves = {
        'U': (0, 1),
        'D': (0, -1),
        'R': (1, 0),
        'L': (-1, 0),
    }
    tail_moves = {
        (2, 0): (1, 0),
        (-2, 0): (-1, 0),
        (0, 2): (0, 1),
        (0, -2): (0, -1),
        (2, 1): (1, 1),
        (1, 2): (1, 1),
        (2, -1): (1, -1),
        (1, -2): (1, -1),
        (-2, 1): (-1, 1),
        (-1, 2): (-1, 1),
        (-2, -1): (-1, -1),
        (-1, -2): (-1, -1),
        (2, 2): (1, 1),
        (-2, 2): (-1, 1),
        (2, -2): (1, -1),
        (-2, -2): (-1, -1),
    }
    with open('day_9.txt') as in_file:
        for command in in_file:
            direction, steps = command.strip().split()
            for _ in range(int(steps)):
                head = tuple(h + m for h, m in zip(head, head_moves[direction]))
                for i, tail in enumerate(tails):
                    prev = head if i == 0 else tails[i - 1]
                    diff = tuple(p - t for p, t in zip(prev, tail))
                    move = tail_moves.get(diff, (0, 0))
                    tails[i] = tuple(t + m for t, m in zip(tail, move))
                history.add(tails[-1])
    return len(history)


def day_10_part_1():
    """Solve Day 10 Part 1 clock cycles puzzle."""
    register_x, cycle, signal, strength = 1, 0, 20, 0
    with open('day_10.txt') as in_file:
        for line in in_file:
            cycle += 1
            if cycle >= signal - 1:
                strength += signal * register_x
                signal += 40
            if line.startswith('addx'):
                cycle += 1
                register_x += int(line.split(' ')[-1])
    return strength


def day_10_part_2():
    """Solve Day 10 Part 2 clock cycles puzzle."""
    register_x, cycle = 1, 0
    draw = list('.' * 240)
    with open('day_10.txt') as in_file:
        for line in in_file:
            if abs(register_x - (cycle % 40)) <= 1:
                draw[cycle] = '#'
            cycle += 1
            if line.startswith('addx'):
                if abs(register_x - (cycle % 40)) <= 1:
                    draw[cycle] = '#'
                cycle += 1
                register_x += int(line.split(' ')[-1])
    lines = [''.join(draw[i:i + 40]) for i in range(0, 240, 40)]
    return '\n'.join(lines)


def day_11_part_1():
    """Solve Day 11 Part 1 monkey throwing puzzle."""
    rounds = 20
    pattern = re.compile(
        r'Monkey (\d+):\s+'
        r'Starting items: ([\d, ]+)\s+'
        r'Operation: new = old ([*+]) (\S+)\s+'
        r'Test: divisible by (\d+)\s+'
        r'If true: throw to monkey (\d+)\s+'
        r'If false: throw to monkey (\d+)'
    )
    monkeys = []
    with open('day_11.txt') as in_file:
        for monkey in in_file.read().split('\n\n'):
            match = pattern.match(monkey).groups()
            monkeys.append({
                'id': int(match[0]),
                'items': [int(item) for item in match[1].split(', ')],
                'increase': {
                    'operator': match[2],
                    'value': 'old' if match[3] == 'old' else int(match[3]),
                },
                'test': {
                    'divisor': int(match[4]),
                    True: int(match[5]),
                    False: int(match[6])
                },
                'inspections': 0,
            })
    for _ in range(rounds):
        for monkey in monkeys:
            while monkey['items']:
                # Pull item for inspection
                item = monkey['items'].pop(0)
                monkey['inspections'] += 1
                # Increase worry
                mi = monkey['increase']
                worry = item if mi['value'] == 'old' else mi['value']
                if mi['operator'] == '+':
                    item += worry
                elif mi['operator'] == '*':
                    item *= worry
                else:
                    raise ValueError(f"Operator {mi['operator']} not supported.")
                # Decrease worry
                item //= 3
                # Check and throw to next monkey
                mt = monkey['test']
                test = (item % mt['divisor'] == 0)
                monkeys[mt[test]]['items'].append(item)
    inspections = sorted([monkey['inspections'] for monkey in monkeys])
    return inspections[-1] * inspections[-2]


def day_11_part_2():
    """Solve Day 11 Part 2 monkey throwing puzzle."""
    rounds = 10000
    pattern = re.compile(
        r'Monkey (\d+):\s+'
        r'Starting items: ([\d, ]+)\s+'
        r'Operation: new = old ([*+]) (\S+)\s+'
        r'Test: divisible by (\d+)\s+'
        r'If true: throw to monkey (\d+)\s+'
        r'If false: throw to monkey (\d+)'
    )
    monkeys = []
    gcd = 1
    with open('day_11.txt') as in_file:
        for monkey in in_file.read().split('\n\n'):
            match = pattern.match(monkey).groups()
            monkeys.append({
                'id': int(match[0]),
                'items': [int(item) for item in match[1].split(', ')],
                'increase': {
                    'operator': match[2],
                    'value': 'old' if match[3] == 'old' else int(match[3]),
                },
                'test': {
                    'divisor': int(match[4]),
                    True: int(match[5]),
                    False: int(match[6])
                },
                'inspections': 0,
            })
            gcd *= int(match[4])
    for _ in range(rounds):
        for monkey in monkeys:
            while monkey['items']:
                # Pull item for inspection
                item = monkey['items'].pop(0)
                monkey['inspections'] += 1
                # Increase worry
                mi = monkey['increase']
                worry = item if mi['value'] == 'old' else mi['value']
                if mi['operator'] == '+':
                    item += worry
                elif mi['operator'] == '*':
                    item *= worry
                else:
                    raise ValueError(f"Operator {mi['operator']} not supported.")
                # Modulo worry (to avoid big int overflow)
                item %= gcd
                # Check and throw to next monkey
                mt = monkey['test']
                test = (item % mt['divisor'] == 0)
                monkeys[mt[test]]['items'].append(item)
    inspections = sorted([monkey['inspections'] for monkey in monkeys])
    return inspections[-1] * inspections[-2]


def day_12_part_1():
    """Solve Day 12 Part 1 hill climbing puzzle.

    This was made to be an A* algorithm, and my literal graduate thesis is a
    waste if I can't write one of these. EDIT: This is short enough for BFS.
    """
    with open('day_12.txt') as in_file:
        grid = [list(row) for row in in_file.read().splitlines()]
    # Build indexed adjacency list to convert to a graph
    start, end = None, None
    nodes = {}
    for i, row in enumerate(grid):
        for j, cell in enumerate(row):
            if cell == 'S':
                start = (i, j)
                cell = 'a'
            elif cell == 'E':
                end = (i, j)
                cell = 'z'
            nodes[(i, j)] = {
                'value': ord(cell) - ord('a'),
                'neighbors': [],
                'steps': 1000000,
            }
    for (i, j), node in nodes.items():
        for neighbor in [(i - 1, j), (i + 1, j), (i, j - 1), (i, j + 1)]:
            if neighbor in nodes and nodes[neighbor]['value'] <= node['value'] + 1:
                node['neighbors'].append(neighbor)
    # Breadth-first search the graph to calculate steps to get to each node
    nodes[start]['steps'] = 0
    queue = [start]
    while queue:
        node = nodes[queue.pop(0)]
        for neighbor_location in node['neighbors']:
            neighbor = nodes[neighbor_location]
            steps = node['steps'] + 1
            if steps < neighbor['steps']:
                neighbor['steps'] = steps
                queue.append(neighbor_location)
            if neighbor_location == end:
                break
    return nodes[end]['steps']


def day_12_part_2():
    """Solve Day 12 Part 2 hill climbing puzzle."""
    with open('day_12.txt') as in_file:
        grid = [list(row) for row in in_file.read().splitlines()]
    # Build indexed adjacency list to convert to a graph
    start = None
    nodes = {}
    for i, row in enumerate(grid):
        for j, cell in enumerate(row):
            if cell == 'S':
                cell = 'a'
            elif cell == 'E':
                start = (i, j)
                cell = 'z'
            nodes[(i, j)] = {
                'value': ord('z') - ord(cell),
                'neighbors': [],
                'steps': 1000000,
            }
    for (i, j), node in nodes.items():
        for neighbor in [(i - 1, j), (i + 1, j), (i, j - 1), (i, j + 1)]:
            if neighbor in nodes and nodes[neighbor]['value'] <= node['value'] + 1:
                node['neighbors'].append(neighbor)
    # Breadth-first search the graph to calculate steps to get to each node
    nodes[start]['steps'] = 0
    queue = [start]
    while queue:
        node = nodes[queue.pop(0)]
        for neighbor_location in node['neighbors']:
            neighbor = nodes[neighbor_location]
            steps = node['steps'] + 1
            if steps < neighbor['steps']:
                neighbor['steps'] = steps
                queue.append(neighbor_location)
    return min(node['steps'] for node in nodes.values() if node['value'] == 25)


def day_13_part_1():
    """Solve Day 13 Part 1 pair matching puzzle."""
    LOWER, SAME, HIGHER = -1, 0, 1
    with open('day_13.txt') as in_file:
        pairs = [
            [json.loads(row) for row in pair.strip().split('\n')]
            for pair in in_file.read().split('\n\n')
        ]

    def compare(left, right):
        """Recursively compare left and right pairs."""
        if left is None:
            return LOWER
        if right is None:
            return HIGHER
        if isinstance(left, int) and isinstance(right, int):
            if left < right:
                return LOWER
            if left > right:
                return HIGHER
            return SAME
        if isinstance(left, list) and isinstance(right, list):
            for l, r in itertools.zip_longest(left, right):
                result = compare(l, r)
                if result != SAME:
                    return result
            return SAME
        return compare(
            [left] if isinstance(left, int) else left,
            [right] if isinstance(right, int) else right
        )

    return sum(i for i, pair in enumerate(pairs, 1) if compare(*pair) == LOWER)


def day_13_part_2():
    """Solve Day 13 Part 2 pair matching puzzle."""
    LOWER, SAME, HIGHER = -1, 0, 1
    dividers = [[2]], [[6]]
    with open('day_13.txt') as in_file:
        packets = [
            json.loads(row) for row in in_file.read().split('\n')
            if row.strip()
        ]
    packets += dividers

    def compare(left, right):
        """Recursively compare left and right pairs."""
        if left is None:
            return LOWER
        if right is None:
            return HIGHER
        if isinstance(left, int) and isinstance(right, int):
            if left < right:
                return LOWER
            if left > right:
                return HIGHER
            return SAME
        if isinstance(left, list) and isinstance(right, list):
            for l, r in itertools.zip_longest(left, right):
                result = compare(l, r)
                if result != SAME:
                    return result
            return SAME
        return compare(
            [left] if isinstance(left, int) else left,
            [right] if isinstance(right, int) else right
        )

    packets.sort(key=functools.cmp_to_key(compare))
    first, second = [packets.index(divider) for divider in dividers]
    return (first + 1) * (second + 1)


def day_14_part_1():
    """Solve Day 14 Part 1 sand filling puzzle."""
    AIR, SAND, ROCK, SOURCE = '.', 'o', '#', '+'
    with open('day_14.txt') as in_file:
        rock_paths = [json.loads(f"[[{row.replace('->', '],[')}]]")
                      for row in in_file]
    # Normalize the data to simplify visualization
    min_x, max_x, max_y = 1000, 0, 0
    for rock_path in rock_paths:
        for x, y in rock_path:
            min_x, max_x, max_y = min(min_x, x), max(max_x, x), max(max_y, y)
    for i, rock_path in enumerate(rock_paths):
        rock_paths[i] = [(x - min_x, y) for x, y in rock_path]
    max_x -= min_x
    grid = [[AIR] * (max_y + 1) for _ in range(max_x + 1)]
    source = (500 - min_x, 0)
    grid[source[0]][source[1]] = SOURCE
    # Load grid with rocks
    for rock_path in rock_paths:
        for (x1, y1), (x2, y2) in zip(rock_path[:-1], rock_path[1:]):
            if y1 == y2:
                start, end = min(x1, x2), max(x1, x2) + 1
                points = ((x, y1) for x in range(start, end))
            elif x1 == x2:
                start, end = min(y1, y2), max(y1, y2) + 1
                points = ((x1, y) for y in range(start, end))
            for x, y in points:
                grid[x][y] = ROCK
    # Fill structure with sand until full
    done = False
    number_of_grains = 0
    while not done:
        x, y = source
        while True:
            if y == max_y:
                done = True
                break
            elif x in [-1, max_x] or grid[x][y + 1] == AIR:
                y += 1
            elif x == 0 or grid[x - 1][y + 1] == AIR:
                x, y = x - 1, y + 1
            elif x == max_x or grid[x + 1][y + 1] == AIR:
                x, y = x + 1, y + 1
            else:
                break
        if not done:
            number_of_grains += 1
            grid[x][y] = SAND
    # print('\n'.join([''.join(row) for row in zip(*grid)]))
    return number_of_grains



def day_14_part_2():
    """Solve Day 14 Part 2 sand filling puzzle."""
    AIR, SAND, ROCK, SOURCE = '.', 'o', '#', '+'
    with open('day_14.txt') as in_file:
        rock_paths = [json.loads(f"[[{row.replace('->', '],[')}]]")
                      for row in in_file]
    # Normalize the data to simplify visualization
    min_x, max_x, max_y = 1000, 0, 0
    for rock_path in rock_paths:
        for x, y in rock_path:
            min_x, max_x, max_y = min(min_x, x), max(max_x, x), max(max_y, y)
    # Add very large ("infinite") floor
    min_x, max_x, max_y = min_x - 500, max_x + 500, max_y + 2
    rock_paths.append([[min_x, max_y], [max_x, max_y]])
    for i, rock_path in enumerate(rock_paths):
        rock_paths[i] = [(x - min_x, y) for x, y in rock_path]
    max_x -= min_x
    grid = [[AIR] * (max_y + 1) for _ in range(max_x + 1)]
    source = (500 - min_x, 0)
    grid[source[0]][source[1]] = SOURCE
    # Load grid with rocks
    for rock_path in rock_paths:
        for (x1, y1), (x2, y2) in zip(rock_path[:-1], rock_path[1:]):
            if y1 == y2:
                start, end = min(x1, x2), max(x1, x2) + 1
                points = ((x, y1) for x in range(start, end))
            elif x1 == x2:
                start, end = min(y1, y2), max(y1, y2) + 1
                points = ((x1, y) for y in range(start, end))
            for x, y in points:
                grid[x][y] = ROCK
    # Fill structure with sand until full
    number_of_grains = 0
    while True:
        x, y = source
        while True:
            if grid[x][y + 1] == AIR:
                y += 1
            elif grid[x - 1][y + 1] == AIR:
                x, y = x - 1, y + 1
            elif grid[x + 1][y + 1] == AIR:
                x, y = x + 1, y + 1
            else:
                break
        number_of_grains += 1
        grid[x][y] = SAND
        if (x, y) == source:
            break
    return number_of_grains


def day_15_part_1():
    """Solve Day 15 Part 1 sensor-beacon puzzle."""
    pattern = re.compile(r'Sensor at x=(\d+), y=(\d+): '
                         r'closest beacon is at x=(-?\d+), y=(-?\d+)\n')
    with open('day_15.txt') as in_file:
        sensors = [[int(x) for x in pattern.match(row).groups()]
                   for row in in_file]
    # Keep a set of all x coords where a beacon cannot be located
    y_target = 2_000_000
    no_beacon = set()
    for x_sensor, y_sensor, x_beacon, y_beacon in sensors:
        radius = abs(x_beacon - x_sensor) + abs(y_beacon - y_sensor)
        if y_sensor - radius <= y_target <= y_sensor + radius:
            dy = abs(y_target - y_sensor)
            dx = radius - dy
            no_beacon.update({*range(x_sensor - dx, x_sensor + dx + 1)})
    # Remove beacons that may exist in the target row
    for _, _, x_beacon, y_beacon in sensors:
        if y_beacon == y_target and x_beacon in no_beacon:
            no_beacon.remove(x_beacon)
    return len(no_beacon)


def day_15_part_2():
    """Solve Day 15 Part 2 sensor-beacon puzzle."""
    pattern = re.compile(r'Sensor at x=(\d+), y=(\d+): '
                         r'closest beacon is at x=(-?\d+), y=(-?\d+)\n')
    with open('day_15.txt') as in_file:
        sensors = [[int(x) for x in pattern.match(row).groups()]
                   for row in in_file]
    # Keep a set of all x coord ranges where a beacon cannot be located
    max_coordinate = 4_000_000
    y_targets = range(max_coordinate + 1)
    for y_target in y_targets:
        no_beacon = []
        for x_sensor, y_sensor, x_beacon, y_beacon in sensors:
            radius = abs(x_beacon - x_sensor) + abs(y_beacon - y_sensor)
            if y_sensor - radius <= y_target <= y_sensor + radius:
                dy = abs(y_target - y_sensor)
                dx = radius - dy
                left = min(max(x_sensor - dx, 0), max_coordinate)
                right = min(max(x_sensor + dx, 0), max_coordinate)
                no_beacon.append((left, right))
        no_beacon.sort()
        i = 1
        while i < len(no_beacon):
            x1_min, x1_max = no_beacon[0]
            x2_min, x2_max = no_beacon[i]
            if x2_min - 1 <= x1_max < x2_max:
                no_beacon[0] = (x1_min, x2_max)
            i += 1
        x = no_beacon[0][1]
        if x != max_coordinate:
            break
    return (x + 1) * 4_000_000 + y_target


def day_16_part_1():
    """Solve Day 16 Part 1 valve opening puzzle."""
    pattern = re.compile(r'Valve (\w+) has flow rate=(\d+); '
                         r'tunnels? leads? to valves? (.+)\n')
    valves = {}
    with open('day_16.txt') as in_file:
        for row in in_file:
            valve, rate, neighbors = pattern.match(row).groups()
            valves[valve] = {
                'rate': int(rate),
                'neighbors': neighbors.split(', '),
            }
    # Important! Convert the valves into a weighted graph containing only
    # nodes with usable valves (rate > 0). This greatly simplifies the search
    # space and smooths over corner cases.
    indices = {v: i for i, v in enumerate(valves)}
    distances = [[1000] * len(indices) for _ in indices]
    for name, valve in valves.items():
        valve_index = indices[name]
        for neighbor in valve['neighbors']:
            neighbor_index = indices[neighbor]
            distances[valve_index][neighbor_index] = 1
    # Floyd–Warshall DP algorithm for calculating edge weights
    for k, i, j in itertools.product(indices.values(), indices.values(), indices.values()):
        distances[i][j] = min(distances[i][j], distances[i][k] + distances[k][j])

    root = 'AA'
    nodes = [
        {'name': name, 'rate': valve['rate']}
        for name, valve in valves.items()
        if valve['rate'] > 0 or name == root
    ]
    for i, node in enumerate(nodes):
        node['neighbors'] = {
            j: distances[indices[node['name']]][indices[neighbor['name']]]
            for j, neighbor in enumerate(nodes)
            if i != j
        }
    root_index = next(i for i, node in enumerate(nodes) if node['name'] == root)

    depth = 30
    max_score = 0
    queue = [{
        'node': root_index,
        'steps': 0,
        'traversed': [False] * len(nodes),
        'score': 0,
    }]
    while queue:
        entry = queue.pop()
        if entry['steps'] == depth:
            max_score = max(entry['score'], max_score)
        else:
            moved = False
            i = entry['node']
            for j, distance in nodes[i]['neighbors'].items():
                new_step = entry['steps'] + distance + 1
                if new_step + distance < depth + 1 and not entry['traversed'][j]:
                    moved = True
                    new_traversed = entry['traversed'].copy()
                    new_traversed[i] = True
                    new_traversed[j] = True
                    new_score = entry['score'] + nodes[j]['rate'] * (depth - new_step)
                    queue.append({
                        'node': j,
                        'steps': new_step,
                        'traversed': new_traversed,
                        'score': new_score,
                    })
            if not moved:
                queue.append({
                    'node': entry['node'],
                    'steps': depth,
                    'traversed': entry['traversed'],
                    'score': entry['score'],
                })
    return max_score


def day_16_part_2():
    """Solve Day 16 Part 2 valve opening puzzle.

    This took me a *long* time. The breakthrough came from simplifying the
    valves into a weighted graph.
    """
    pattern = re.compile(r'Valve (\w+) has flow rate=(\d+); '
                         r'tunnels? leads? to valves? (.+)\n')
    valves = {}
    with open('day_16.txt') as in_file:
        for row in in_file:
            valve, rate, neighbors = pattern.match(row).groups()
            valves[valve] = {
                'rate': int(rate),
                'neighbors': neighbors.split(', '),
            }
    # Important! Convert the valves into a weighted graph containing only
    # nodes with usable valves (rate > 0). This greatly simplifies the search
    # space and smooths over corner cases.
    indices = {v: i for i, v in enumerate(valves)}
    distances = [[1000] * len(indices) for _ in indices]
    for name, valve in valves.items():
        valve_index = indices[name]
        for neighbor in valve['neighbors']:
            neighbor_index = indices[neighbor]
            distances[valve_index][neighbor_index] = 1
    # Floyd–Warshall DP algorithm for calculating edge weights
    for k, i, j in itertools.product(indices.values(), indices.values(), indices.values()):
        distances[i][j] = min(distances[i][j], distances[i][k] + distances[k][j])

    root = 'AA'
    nodes = [
        {'name': name, 'rate': valve['rate']}
        for name, valve in valves.items()
        if valve['rate'] > 0 or name == root
    ]
    for i, node in enumerate(nodes):
        node['neighbors'] = {
            j: distances[indices[node['name']]][indices[neighbor['name']]]
            for j, neighbor in enumerate(nodes)
            if i != j
        }
    root_index = next(i for i, node in enumerate(nodes) if node['name'] == root)

    depth = 26
    max_score = 0
    queue = [{
        'nodes': [root_index, root_index],
        'steps': [0, 0],
        'traversed': [False] * len(nodes),
        'score': 0,
    }]
    while queue:
        entry = queue.pop()
        if all(step == depth for step in entry['steps']):
            max_score = max(entry['score'], max_score)
        else:
            options = [[] for _ in entry['nodes']]
            for i, option, step in zip(entry['nodes'], options, entry['steps']):
                for j, distance in nodes[i]['neighbors'].items():
                    if step + distance < depth and not entry['traversed'][j]:
                        option.append([j, step + distance + 1])
                if not option:
                    option.append([i, depth])
            for (i, step_1), (j, step_2) in itertools.product(*options):
                # Cannot flip same valve
                if i == j:
                    continue
                new_score = entry['score'] + (
                    nodes[i]['rate'] * (depth - step_1) +
                    nodes[j]['rate'] * (depth - step_2)
                )
                # A manual heuristic to chop paths that are not scoring well
                if new_score + 100 * (depth - min(step_1, step_2)) > max_score:
                    new_traversed = entry['traversed'].copy()
                    new_traversed[i] = True
                    new_traversed[j] = True
                    queue.append({
                        'nodes': [i, j],
                        'steps': [step_1, step_2],
                        'traversed': new_traversed,
                        'score': new_score,
                    })
    return max_score


parser = argparse.ArgumentParser(description="Run 2022 Advent of Code solutions.")
parser.add_argument('day', type=int, choices=range(1, 26), metavar='[1, 25]',
                    help='Advent code day to run.')
args = parser.parse_args()
part_1, part_2 = locals()[f'day_{args.day}_part_1'], locals()[f'day_{args.day}_part_2']
print(f"Part 1:\n{part_1()}\n")
print(f"Part 2:\n{part_2()}")