private SearchResult Negamax(int currentDepth, Grid state, int alpha, int beta,
            bool allowNullWindow = true)
        {
            int currentPlayer = currentDepth & 1;
            int originalAlpha = alpha;

            totalNodesSearched++;
            
            ulong validMovesMask = state.GetValidMovesMask();
            ulong playerThreats = state.GetThreats(currentPlayer);

            // If the player can win on this move then return immediately,
            // no more evaluation is needed.
            ulong currentPlayerThreats = validMovesMask & playerThreats;
            if (currentPlayerThreats != 0)
            {
                endNodesSearched++;

                if (currentDepth == moveNumber)
                {
                    finalMove = GetFirstColumnOfThreatBoard(currentPlayerThreats);
                }

                return new SearchResult(infinity, currentDepth);
            }

            ulong opponentThreats = state.GetThreats(1 - currentPlayer);

            // If the opponent could win in one move on this position, then the
            // current player must play that move instead.
            ulong currentOpponentThreats = validMovesMask & opponentThreats;
            if (currentOpponentThreats != 0)
            {
                int forcedMove = GetFirstColumnOfThreatBoard(currentOpponentThreats);

                if (currentDepth == moveNumber)
                {
                    finalMove = forcedMove;
                }

                // Remove the forced move from the threat board.
                currentOpponentThreats &= ~(0x3FUL << (forcedMove * (state.Height + 1)));

                // If there is another threat return the loss immediately, otherwise
                // play the forced move.
                if (currentOpponentThreats != 0)
                {
                    endNodesSearched++;

                    return new SearchResult(-infinity, currentDepth + 1);
                }

                // Take the single forced move.
                state.Move(forcedMove, currentPlayer);
                SearchResult childResult
                    = -Negamax(currentDepth + 1, state, -beta, -alpha, allowNullWindow);
                state.UndoMove(forcedMove, currentPlayer);
                
                return childResult;
            }

            // This must be a draw if there are no forced moves and only two
            // moves left in the game.
            if (currentDepth >= maxMoves - 1)
            {
                endNodesSearched++;

                return new SearchResult(0, currentDepth);
            }

            // Will be set to the best move of the lookup.
            int entryBestMove = -1;

            // Check if the position or the flipped position should be used for the lookup.
            ulong hash = Math.Min(state.Hash, state.FlippedHash);
            bool usingFlippedPosition = hash != state.Hash;

            // Check if this state has already been visited.
            ulong entry;
            if (transpositionTable.Lookup(hash, out entry))
            {
                // If the state has been visited, then return immediately or
                // improve the alpha and beta values depending on the node type.
                tableLookups++;

                // The score is stored in bits 8 to 15 of the entry.
                int encoded = (int)(((entry >> 8) & 0xFF) - 128);
                SearchResult lookupResult = new SearchResult { score = encoded };

                // The type is stored in bits 6 to 7 of the entry.
                int entryType = (int)((entry >> 6) & 0x3);
                
                // Flip the best move if necessary.
                entryBestMove = (int)((entry >> 16) & 0x7);
                if (usingFlippedPosition)
                {
                    entryBestMove = 6 - entryBestMove;
                }

                switch (entryType)
                {
                    // If the score is exact, there is no need to check anything
                    // else, so return the score of the entry.
                    case NodeTypeExact:
                        if (currentDepth == moveNumber)
                        {
                            finalMove = entryBestMove;
                        }
                        return lookupResult;

                    // If the entry score is an upper bound on the actual score,
                    // see if the current upper bound can be reduced.
                    case NodeTypeUpper:
                        beta = Math.Min(beta, lookupResult.GetValue());
                        break;

                    // If the entry score is a lower bound on the actual score,
                    // see if the current lower bound can be increased.
                    case NodeTypeLower:
                        alpha = Math.Max(alpha, lookupResult.GetValue());
                        break;
                }

                // At this point alpha or beta may have been improved, so check if
                // this is a cuttoff.
                if (alpha >= beta)
                {
                    alphaBetaCutoffs++;

                    if (currentDepth == moveNumber)
                    {
                        finalMove = entryBestMove;
                    }

                    return lookupResult;
                }
            }

            // A bitmap where a 1 means the corresponding move has already been
            // checked. Initialised with a 1 at all invalid moves.
            ulong checkedMoves = state.GetInvalidMovesMask();

            SearchResult result = new SearchResult { score = int.MinValue };

            int bestMove = -1;
            int index = -1;
            bool isFirstChild = true;

            // If no parent of this position has tried a null-window search yet,
            // try guessing the score of this position.
            if (allowNullWindow)
            {
                int guess = (currentPlayer == 0)
                    ? GuessScore(0, playerThreats, opponentThreats)
                    : GuessScore(1, opponentThreats, playerThreats);

                // Check if there is a guess for this position.
                if (guess != 0)
                {
                    int nullAlpha;
                    int nullBeta;
                    int expectedScore;

                    // If this is likely to be a win, search this position with a
                    // null window centered at +infinity.
                    if (guess == 1)
                    {
                        nullAlpha = infinity;
                        nullBeta = infinity + 1;
                        expectedScore = infinity;
                    }

                    // If this is likely to be a loss.
                    else
                    {
                        nullAlpha = -infinity - 1;
                        nullBeta = -infinity;
                        expectedScore = -infinity;
                    }

                    allowNullWindow = false;
                    SearchResult nullResult = Negamax(
                            currentDepth, state, nullAlpha, nullBeta, allowNullWindow);

                    // If the guess is correct, return immediately.
                    if (nullResult.GetValue() == expectedScore)
                    {
                        correctGuesses++;
                        return nullResult;
                    }

                    incorrectGuesses++;
                }
            }

            // Find the best move recursively.
            // checkMoves will be equal to bottomRow when there are no more valid
            // moves.
            while (checkedMoves != Grid.bottomRow)
            {
                int move = GetNextMove(ref index, ref checkedMoves, entryBestMove,
                    validMovesMask, playerThreats);
                if (isFirstChild)
                {
                    bestMove = move;
                }

                // Apply the move and recurse.
                state.Move(move, currentPlayer);
                SearchResult childResult
                    = -Negamax(currentDepth + 1, state, -beta, -alpha, allowNullWindow);
                state.UndoMove(move, currentPlayer);

                if (childResult.score > result.score)
                {
                    result = childResult;
                    bestMove = move;

                    if (result.GetValue() > alpha)
                    {
                        alpha = result.GetValue();
                        // Check if this a cutoff.
                        if (alpha >= beta)
                        {
                            betaCutoffs++;
                            if (isFirstChild)
                            {
                                betaCutoffsOnFirstChild++;
                            }
                            if (index <= 0)
                            {
                                betaCutoffsOnOrderedChildren++;
                            }

                            break;
                        }
                    }
                }

                isFirstChild = false;
            }

            Debug.Assert(bestMove != -1);

            // Determine the type of node, so the score can be used correctly
            // after a lookup.
            int flag;
            if (result.score <= originalAlpha)
            {
                flag = NodeTypeUpper;
                allNodes++;
            }
            else if (result.score >= beta)
            {
                flag = NodeTypeLower;
                cutNodes++;
            }
            else
            {
                flag = NodeTypeExact;
                pvNodes++;
            }

            // Store the score in the t-table in case the same state is reached later.
            int moveToStore = (usingFlippedPosition) ? 6 - bestMove : bestMove;
            transpositionTable.Add(currentDepth, moveToStore, hash, result.score, flag);

            if (currentDepth == moveNumber)
            {
                finalMove = bestMove;
            }

            return result;
        }