Advent of code 2020/20
Ajax Direct

Answer 870ms

Part 1 : 108603771107737 Part 2 : 2129
// ==================================================
// > PARSING : Index all tiles and edges
// ==================================================
// List all possible tiles orientation
$tiles = $input->split("\n\n")->mapAssoc(function ($i, $tile) {
    $id = $tile->numbers()[0];

    $grid = grid($tile->lines->slice(1)->join("\n"));
    $conf = [$grid, (clone $grid)->reverseX()];

    for ($r = 0; $r < 3; $r++) {
        $conf[] = (clone $conf[$r * 2])->rotate();
        $conf[] = (clone $conf[$r * 2 + 1])->rotate();
    }

    return [$id => $conf];
});

// Keep only the edges as numbers
function edgenum($edge) {
    return bindec(str_replace([".", "#"], [0, 1], $edge->join("")));
}

$edges = $tiles->map(fn ($conf) =>
    set($conf)->map(fn ($c) => [
        edgenum($c->rows()[0]), edgenum($c->columns()[9]),
        edgenum($c->rows()[9]), edgenum($c->columns()[0])
    ]
));

// Index left and top edges to find matches quicker
foreach ($edges as $id=>$tile) foreach ($tile as $c=>$conf) foreach ($conf as $e=>$edge) {
    if ($e == 3) $left_edges[$edge][] = "$id:$c";
    if ($e == 0) $top_edges[$edge][] = "$id:$c";
}

// ==================================================
// > PART 1 : Solve the puzzle
// ==================================================
$size = sqrt(count($edges));
$graph = (new Graph(function ($graph) use ($edges, $left_edges, $top_edges, $size) {
    // Start : return every possible tile
    if ($graph->current == "@") {
        foreach ($edges as $id=>$tile) foreach ($tile as $c=>$conf) {
            $next[] = $id . ":" . $c;
        }
        return array_fill_keys($next, 1);
    }

    // Select possible next tiles
    $order = array_map(fn ($t) => explode(":", $t), explode("|", $graph->current));
    $place = count($order);

    // Check top and left tiles/edges
    $lt = $place % $size ? $order[count($order) - 1] : false;
    $tt = $place >= $size ? $order[$place - $size] : false;
    $le = $lt ? $edges[$lt[0]][$lt[1]][1] : false;
    $te = $tt ? $edges[$tt[0]][$tt[1]][2] : false;

    $ple = $le ? $left_edges[$le] : false;
    $pte = $te ? $top_edges[$te] : false;
    $p = $ple && $pte ? array_intersect($ple, $pte) : ($ple ?: $pte);

    // Remove all already used
    $used = array_column($order, 0);
    $p = array_filter($p, fn ($id) => !in_array(substr($id, 0, 4), $used));

    $next = [];
    foreach ($p as $s) $next[$graph->current . "|" . $s] = 1;
    return $next;
}, "highest"))

// End when all tiles are placed
->defineEnd(function ($graph) use ($edges) {
    return $graph->values[$graph->current] == count($edges);
})

// On end : return the solved puzzle's tiles [id, orientation]
->onEnd(function ($graph) use ($size) {
    return set(explode("|", $graph->current))->map(fn ($c) => explode(":", $c));
});

$puzzle = $graph->explore("@");
$corners = set([$puzzle[0], $puzzle[$size - 1], $puzzle[$size * ($size - 1)], $puzzle[$size * $size - 1]]);
$solution_1 = $corners->column(0)->multiply();

// ==================================================
// > PART 2
// ==================================================
// Assemble the puzzle
$grid = grid();
foreach ($puzzle as $i=>$conf) {
    $tile = grid();
    $tile->rows = $tiles[$conf[0]][$conf[1]]->rows->slice(1, -1)->map(fn ($r) => $r->slice(1, -1));

    $grid->insert(
        [($i % $size) * 8, floor($i / $size) * 8],
        $tile
    );
}

// The monster tiles' positions
$monster = grid(implode("\n", [
    "                  # ",
    "#    ##    ##    ###",
    " #  #  #  #  #  #   "]))
->filter()->keys();

// Find sea monsters
for ($i = 0; $i < 8; $i++) {
    $monsters = $grid->keys()->map(function ($xy) use ($monster, $grid) {
        $parts = [];
        foreach ($monster as [$mx, $my]) {
            $pos = [$xy[0] + $mx, $xy[1] + $my];
            if ($grid->get($pos) != "#") return [];
            $parts[] = set($pos);
        }
        return $parts;
    });

    if (($monsters = $monsters->filter())->count()) {
        $solution_2 = $grid->cells()->keep("#")->count() - $monsters->merge()->unique()->count();
        break;
    }

    if ($i == 3) {
        $grid->rotate()->reverseX();
    } else {
        $grid->rotate();
    }
}