/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.apache.storm.daemon.logviewer.handler;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.internal.verification.VerificationModeFactory.times;

import java.io.File;
import java.io.IOException;
import java.net.UnknownHostException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

import org.apache.storm.DaemonConfig;
import org.apache.storm.daemon.logviewer.LogviewerConstant;
import org.apache.storm.daemon.logviewer.utils.ResourceAuthorizer;
import org.apache.storm.daemon.ui.InvalidRequestException;
import org.apache.storm.metric.StormMetricsRegistry;
import org.apache.storm.streams.tuple.Tuple3;
import org.apache.storm.utils.Utils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;

public class LogviewerLogSearchHandlerTest {

    @Nested
    public class SearchViaRestApi {

        private final String pattern = "needle";
        private final String expectedHost = "dev.null.invalid";
        private final Integer expectedPort = 8888;
        private final String logviewerUrlPrefix = "http://" + expectedHost + ":" + expectedPort;

        /*
         * When we click a link to the logviewer, we expect the match line to be somewhere near the middle of the page. So we subtract half
         * of the default page length from the offset at which we found the match.
         */
        private final Function<Integer, Integer> expOffsetFn = arg -> (LogviewerConstant.DEFAULT_BYTES_PER_PAGE / 2 - arg);

        @Test
        public void testSearchViaRestApiThrowsIfBogusFileIsGiven() throws InvalidRequestException {
            LogviewerLogSearchHandler handler = getSearchHandler();
            assertThrows(RuntimeException.class, () -> handler.substringSearch(null, "a string"));
        }

        @Test
        public void testLogviewerLinkCentersTheMatchInThePage() throws UnknownHostException {
            String expectedFname = "foobar.log";

            LogviewerLogSearchHandler handler = getSearchHandlerWithPort(expectedPort);
            Utils prevUtils = null;
            try {
                Utils mockedUtil = mock(Utils.class);
                prevUtils = Utils.setInstance(mockedUtil);

                when(mockedUtil.hostname()).thenReturn(expectedHost);

                String actualUrl = handler.urlToMatchCenteredInLogPage(new byte[42], new File(expectedFname).toPath(), 27526, 8888);

                assertEquals("http://" + expectedHost + ":" + expectedPort + "/api/v1/log?file=" + expectedFname
                    + "&start=1947&length=" + LogviewerConstant.DEFAULT_BYTES_PER_PAGE, actualUrl);
            } finally {
                Utils.setInstance(prevUtils);
            }
        }

        @Test
        public void testLogviewerLinkCentersTheMatchInThePageDaemon() throws UnknownHostException {
            String expectedFname = "foobar.log";

            LogviewerLogSearchHandler handler = getSearchHandlerWithPort(expectedPort);
            Utils prevUtils = null;
            try {
                Utils mockedUtil = mock(Utils.class);
                prevUtils = Utils.setInstance(mockedUtil);

                when(mockedUtil.hostname()).thenReturn(expectedHost);

                String actualUrl = handler.urlToMatchCenteredInLogPageDaemonFile(new byte[42], new File(expectedFname).toPath(), 27526, 8888);

                assertEquals("http://" + expectedHost + ":" + expectedPort + "/api/v1/daemonlog?file=" + expectedFname
                    + "&start=1947&length=" + LogviewerConstant.DEFAULT_BYTES_PER_PAGE, actualUrl);
            } finally {
                Utils.setInstance(prevUtils);
            }
        }

        @SuppressWarnings("checkstyle:LineLength")
        @Test
        public void testReturnsCorrectBeforeAndAfterContext() throws Exception {
            Utils prevUtils = null;
            try {
                Utils mockedUtil = mock(Utils.class);
                prevUtils = Utils.setInstance(mockedUtil);

                when(mockedUtil.hostname()).thenReturn(expectedHost);

                final File file = new File(String.join(File.separator, "src", "test", "resources"),
                    "logviewer-search-context-tests.log.test");

                Map<String, Object> expected = new HashMap<>();
                expected.put("isDaemon", "no");
                expected.put("searchString", pattern);
                expected.put("startByteOffset", 0);

                List<Map<String, Object>> matches = new ArrayList<>();

                matches.add(buildMatchData(0, "",
                    " needle000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000needle ",
                    pattern,
                    "/api/v1/log?file=test" + encodedFileSeparator() + "resources" + encodedFileSeparator() + file.getName()
                    + "&start=0&length=51200"
                ));

                matches.add(buildMatchData(7, "needle ",
                    "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000needle needle\n",
                    pattern,
                    "/api/v1/log?file=test" + encodedFileSeparator() + "resources" + encodedFileSeparator() + file.getName()
                    + "&start=0&length=51200"
                ));

                matches.add(buildMatchData(127,
                    "needle needle000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
                    " needle\n",
                    pattern,
                    "/api/v1/log?file=test" + encodedFileSeparator() + "resources" + encodedFileSeparator() + file.getName()
                    + "&start=0&length=51200"
                ));

                matches.add(buildMatchData(134,
                    " needle000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000needle ",
                    "\n",
                    pattern,
                    "/api/v1/log?file=test" + encodedFileSeparator() + "resources" + encodedFileSeparator() + file.getName()
                    + "&start=0&length=51200"
                ));

                expected.put("matches", matches);

                LogviewerLogSearchHandler handler = getSearchHandlerWithPort(expectedPort);
                Map<String, Object> searchResult = handler.substringSearch(file.toPath(), pattern);

                assertEquals(expected, searchResult);
            } finally {
                Utils.setInstance(prevUtils);
            }
        }

        @Test
        public void testAreallySmallLogFile() throws Exception {
            Utils prevUtils = null;
            try {
                Utils mockedUtil = mock(Utils.class);
                prevUtils = Utils.setInstance(mockedUtil);

                when(mockedUtil.hostname()).thenReturn(expectedHost);

                final File file = new File(String.join(File.separator, "src", "test", "resources"),
                    "small-worker.log.test");

                Map<String, Object> expected = new HashMap<>();
                expected.put("isDaemon", "no");
                expected.put("searchString", pattern);
                expected.put("startByteOffset", 0);

                List<Map<String, Object>> matches = new ArrayList<>();

                matches.add(buildMatchData(7, "000000 ",
                    " 000000\n",
                    pattern,
                    "/api/v1/log?file=test" + encodedFileSeparator() + "resources" + encodedFileSeparator() + file.getName()
                    + "&start=0&length=51200"
                ));

                expected.put("matches", matches);

                LogviewerLogSearchHandler handler = getSearchHandlerWithPort(expectedPort);
                Map<String, Object> searchResult = handler.substringSearch(file.toPath(), pattern);

                assertEquals(expected, searchResult);
            } finally {
                Utils.setInstance(prevUtils);
            }
        }

        @Test
        public void testAreallySmallLogDaemonFile() throws InvalidRequestException, UnknownHostException {
            Utils prevUtils = null;
            try {
                Utils mockedUtil = mock(Utils.class);
                prevUtils = Utils.setInstance(mockedUtil);

                when(mockedUtil.hostname()).thenReturn(expectedHost);

                final File file = new File(String.join(File.separator, "src", "test", "resources"),
                    "small-worker.log.test");

                Map<String, Object> expected = new HashMap<>();
                expected.put("isDaemon", "yes");
                expected.put("searchString", pattern);
                expected.put("startByteOffset", 0);

                List<Map<String, Object>> matches = new ArrayList<>();

                matches.add(buildMatchData(7, "000000 ",
                    " 000000\n",
                    pattern,
                    "/api/v1/daemonlog?file=" + file.getName() + "&start=0&length=51200"
                ));

                expected.put("matches", matches);

                LogviewerLogSearchHandler handler = getSearchHandlerWithPort(expectedPort);
                Map<String, Object> searchResult = handler.substringSearchDaemonLog(file.toPath(), pattern);

                assertEquals(expected, searchResult);
            } finally {
                Utils.setInstance(prevUtils);
            }
        }

        @Test
        public void testNoOffsetReturnedWhenFileEndsOnBufferOffset() throws Exception {
            Utils prevUtils = null;
            try {
                Utils mockedUtil = mock(Utils.class);
                prevUtils = Utils.setInstance(mockedUtil);

                when(mockedUtil.hostname()).thenReturn(expectedHost);

                final File file = new File(String.join(File.separator, "src", "test", "resources"),
                    "test-3072.log.test");

                Map<String, Object> expected = new HashMap<>();
                expected.put("isDaemon", "no");
                expected.put("searchString", pattern);
                expected.put("startByteOffset", 0);

                List<Map<String, Object>> matches = new ArrayList<>();

                matches.add(buildMatchData(3066,
                    ".".repeat(128),
                    "",
                    pattern,
                    "/api/v1/log?file=test" + encodedFileSeparator() + "resources" + encodedFileSeparator() + file.getName()
                    + "&start=0&length=51200"
                ));

                expected.put("matches", matches);

                LogviewerLogSearchHandler handler = getSearchHandlerWithPort(expectedPort);
                Map<String, Object> searchResult = handler.substringSearch(file.toPath(), pattern);
                Map<String, Object> searchResult2 = handler.substringSearch(file.toPath(), pattern, 1);

                assertEquals(expected, searchResult);
                assertEquals(expected, searchResult2);
            } finally {
                Utils.setInstance(prevUtils);
            }
        }

        @SuppressWarnings("checkstyle:LineLength")
        @Test
        public void testNextByteOffsetsAreCorrectForEachMatch() throws Exception {
            Utils prevUtils = null;
            try {
                Utils mockedUtil = mock(Utils.class);
                prevUtils = Utils.setInstance(mockedUtil);

                when(mockedUtil.hostname()).thenReturn(expectedHost);

                File file = new File(String.join(File.separator, "src", "test", "resources"),
                    "test-worker.log.test");

                LogviewerLogSearchHandler handler = getSearchHandlerWithPort(expectedPort);

                List<Tuple3<Integer, Integer, Integer>> dataAndExpected = new ArrayList<>();
                // numMatchesSought, numMatchesFound, expectedNextByteOffset
                dataAndExpected.add(new Tuple3<>(1, 1, 11));
                dataAndExpected.add(new Tuple3<>(2, 2, 2042));
                dataAndExpected.add(new Tuple3<>(3, 3, 2052));
                dataAndExpected.add(new Tuple3<>(4, 4, 3078));
                dataAndExpected.add(new Tuple3<>(5, 5, 3196));
                dataAndExpected.add(new Tuple3<>(6, 6, 3202));
                dataAndExpected.add(new Tuple3<>(7, 7, 6252));
                dataAndExpected.add(new Tuple3<>(8, 8, 6321));
                dataAndExpected.add(new Tuple3<>(9, 9, 6397));
                dataAndExpected.add(new Tuple3<>(10, 10, 6476));
                dataAndExpected.add(new Tuple3<>(11, 11, 6554));
                dataAndExpected.add(new Tuple3<>(12, 12, null));
                dataAndExpected.add(new Tuple3<>(13, 12, null));

                dataAndExpected.forEach(data -> {
                    Map<String, Object> result;
                    try {
                        result = handler.substringSearch(file.toPath(), pattern, data.value1);
                        assertEquals(data.value3, result.get("nextByteOffset"));
                        assertEquals(data.value2.intValue(), ((List) result.get("matches")).size());
                    } catch (InvalidRequestException e) {
                        throw new RuntimeException(e);
                    }
                });

                Map<String, Object> expected = new HashMap<>();
                expected.put("isDaemon", "no");
                expected.put("searchString", pattern);
                expected.put("startByteOffset", 0);
                expected.put("nextByteOffset", 6252);

                List<Map<String, Object>> matches = new ArrayList<>();

                matches.add(buildMatchData(5,
                    "Test ",
                    " is near the beginning of the file.\nThis file assumes a buffer size of 2048 bytes, a max search string size of 1024 bytes, and a",
                    pattern,
                    "/api/v1/log?file=test" + encodedFileSeparator() + "resources" + encodedFileSeparator() + file.getName()
                    + "&start=0&length=51200"
                ));

                matches.add(buildMatchData(2036,
                    "ng 146\npadding 147\npadding 148\npadding 149\npadding 150\npadding 151\npadding 152\npadding 153\nNear the end of a 1024 byte block, a ",
                    ".\nA needle that straddles a 1024 byte boundary should also be detected.\n\npadding 157\npadding 158\npadding 159\npadding 160\npadding",
                    pattern,
                    "/api/v1/log?file=test" + encodedFileSeparator() + "resources" + encodedFileSeparator() + file.getName()
                    + "&start=0&length=51200"
                ));

                matches.add(buildMatchData(2046,
                    "ding 147\npadding 148\npadding 149\npadding 150\npadding 151\npadding 152\npadding 153\nNear the end of a 1024 byte block, a needle.\nA ",
                    " that straddles a 1024 byte boundary should also be detected.\n\npadding 157\npadding 158\npadding 159\npadding 160\npadding 161\npaddi",
                    pattern,
                    "/api/v1/log?file=test" + encodedFileSeparator() + "resources" + encodedFileSeparator() + file.getName()
                    + "&start=0&length=51200"
                ));

                matches.add(buildMatchData(3072,
                    "adding 226\npadding 227\npadding 228\npadding 229\npadding 230\npadding 231\npadding 232\npadding 233\npadding 234\npadding 235\n\n\nHere a ",
                    " occurs just after a 1024 byte boundary.  It should have the correct context.\n\nText with two adjoining matches: needleneedle\n\npa",
                    pattern,
                    "/api/v1/log?file=test" + encodedFileSeparator() + "resources" + encodedFileSeparator() + file.getName()
                    + "&start=0&length=51200"
                ));

                matches.add(buildMatchData(3190,
                    "\n\n\nHere a needle occurs just after a 1024 byte boundary.  It should have the correct context.\n\nText with two adjoining matches: ",
                    "needle\n\npadding 243\npadding 244\npadding 245\npadding 246\npadding 247\npadding 248\npadding 249\npadding 250\npadding 251\npadding 252\n",
                    pattern,
                    "/api/v1/log?file=test" + encodedFileSeparator() + "resources" + encodedFileSeparator() + file.getName()
                    + "&start=0&length=51200"
                ));

                matches.add(buildMatchData(3196,
                    "e a needle occurs just after a 1024 byte boundary.  It should have the correct context.\n\nText with two adjoining matches: needle",
                    "\n\npadding 243\npadding 244\npadding 245\npadding 246\npadding 247\npadding 248\npadding 249\npadding 250\npadding 251\npadding 252\npaddin",
                    pattern,
                    "/api/v1/log?file=test" + encodedFileSeparator() + "resources" + encodedFileSeparator() + file.getName()
                    + "&start=0&length=51200"
                ));

                matches.add(buildMatchData(6246,
                    "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\n\nHere are four non-ascii 1-byte UTF-8 characters: αβγδε\n\n",
                    "\n\nHere are four printable 2-byte UTF-8 characters: ¡¢£¤¥\n\nneedle\n\n\n\nHere are four printable 3-byte UTF-8 characters: ऄअ",
                    pattern,
                    "/api/v1/log?file=test" + encodedFileSeparator() + "resources" + encodedFileSeparator() + file.getName()
                    + "&start=0&length=51200"
                ));

                expected.put("matches", matches);

                Map<String, Object> searchResult = handler.substringSearch(file.toPath(), pattern, 7);

                assertEquals(expected, searchResult);
            } finally {
                Utils.setInstance(prevUtils);
            }
        }

        @SuppressWarnings("checkstyle:LineLength")
        @Test
        public void testCorrectMatchOffsetIsReturnedWhenSkippingBytes() throws Exception {
            Utils prevUtils = null;
            try {
                Utils mockedUtil = mock(Utils.class);
                prevUtils = Utils.setInstance(mockedUtil);

                when(mockedUtil.hostname()).thenReturn(expectedHost);

                final File file = new File(String.join(File.separator, "src", "test", "resources"),
                    "test-worker.log.test");

                int startByteOffset = 3197;

                Map<String, Object> expected = new HashMap<>();
                expected.put("isDaemon", "no");
                expected.put("searchString", pattern);
                expected.put("startByteOffset", startByteOffset);
                expected.put("nextByteOffset", 6252);

                List<Map<String, Object>> matches = new ArrayList<>();

                matches.add(buildMatchData(6246,
                    "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\n\nHere are four non-ascii 1-byte UTF-8 characters: αβγδε\n\n",
                    "\n\nHere are four printable 2-byte UTF-8 characters: ¡¢£¤¥\n\nneedle\n\n\n\nHere are four printable 3-byte UTF-8 characters: ऄअ",
                    pattern,
                    "/api/v1/log?file=test" + encodedFileSeparator() + "resources" + encodedFileSeparator() + file.getName()
                    + "&start=0&length=51200"
                ));

                expected.put("matches", matches);

                LogviewerLogSearchHandler handler = getSearchHandlerWithPort(expectedPort);
                Map<String, Object> searchResult = handler.substringSearch(file.toPath(), pattern, 1, startByteOffset);

                assertEquals(expected, searchResult);
            } finally {
                Utils.setInstance(prevUtils);
            }
        }

        @SuppressWarnings("checkstyle:LineLength")
        @Test
        public void testAnotherPatterns1() throws Exception {
            Utils prevUtils = null;
            try {
                Utils mockedUtil = mock(Utils.class);
                prevUtils = Utils.setInstance(mockedUtil);

                when(mockedUtil.hostname()).thenReturn(expectedHost);

                final File file = new File(String.join(File.separator, "src", "test", "resources"),
                    "test-worker.log.test");

                String pattern = "X".repeat(1024);

                Map<String, Object> expected = new HashMap<>();
                expected.put("isDaemon", "no");
                expected.put("searchString", pattern);
                expected.put("startByteOffset", 0);
                expected.put("nextByteOffset", 6183);

                List<Map<String, Object>> matches = new ArrayList<>();

                matches.add(buildMatchData(4075,
                    "\n\nThe following match of 1024 bytes completely fills half the byte buffer.  It is a search substring of the maximum size......\n\n",
                    "\nThe following max-size match straddles a 1024 byte buffer.\nXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
                    pattern,
                    "/api/v1/log?file=test" + encodedFileSeparator() + "resources" + encodedFileSeparator() + file.getName()
                    + "&start=0&length=51200"
                ));

                matches.add(buildMatchData(5159,
                    "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\nThe following max-size match straddles a 1024 byte buffer.\n",
                    "\n\nHere are four non-ascii 1-byte UTF-8 characters: αβγδε\n\nneedle\n\nHere are four printable 2-byte UTF-8 characters: ¡¢£¤",
                    pattern,
                    "/api/v1/log?file=test" + encodedFileSeparator() + "resources" + encodedFileSeparator() + file.getName()
                    + "&start=0&length=51200"
                ));

                expected.put("matches", matches);

                LogviewerLogSearchHandler handler = getSearchHandlerWithPort(expectedPort);
                Map<String, Object> searchResult = handler.substringSearch(file.toPath(), pattern, 2);

                assertEquals(expected, searchResult);
            } finally {
                Utils.setInstance(prevUtils);
            }
        }

        @SuppressWarnings("checkstyle:LineLength")
        @Test
        public void testAnotherPatterns2() throws Exception {
            Utils prevUtils = null;
            try {
                Utils mockedUtil = mock(Utils.class);
                prevUtils = Utils.setInstance(mockedUtil);

                when(mockedUtil.hostname()).thenReturn(expectedHost);

                final File file = new File(String.join(File.separator, "src", "test", "resources"),
                    "test-worker.log.test");
                String pattern = "𐄀𐄁𐄂";

                Map<String, Object> expected = new HashMap<>();
                expected.put("isDaemon", "no");
                expected.put("searchString", pattern);
                expected.put("startByteOffset", 0);
                expected.put("nextByteOffset", 7176);

                List<Map<String, Object>> matches = new ArrayList<>();

                matches.add(buildMatchData(7164,
                    "padding 372\npadding 373\npadding 374\npadding 375\n\nThe following tests multibyte UTF-8 Characters straddling the byte boundary:   ",
                    "\n\nneedle",
                    pattern,
                    "/api/v1/log?file=test" + encodedFileSeparator() + "resources" + encodedFileSeparator() + file.getName()
                    + "&start=0&length=51200"
                ));

                expected.put("matches", matches);

                LogviewerLogSearchHandler handler = getSearchHandlerWithPort(expectedPort);
                Map<String, Object> searchResult = handler.substringSearch(file.toPath(), pattern, 1);

                assertEquals(expected, searchResult);
            } finally {
                Utils.setInstance(prevUtils);
            }
        }

        @Test
        public void testReturnsZeroMatchesForUnseenPattern() throws UnknownHostException, InvalidRequestException {
            Utils prevUtils = null;
            try {
                Utils mockedUtil = mock(Utils.class);
                prevUtils = Utils.setInstance(mockedUtil);

                String pattern = "Not There";

                when(mockedUtil.hostname()).thenReturn(expectedHost);

                final File file = new File(String.join(File.separator, "src", "test", "resources"),
                    "test-worker.log.test");

                Map<String, Object> expected = new HashMap<>();
                expected.put("isDaemon", "no");
                expected.put("searchString", pattern);
                expected.put("startByteOffset", 0);

                expected.put("matches", Collections.emptyList());

                LogviewerLogSearchHandler handler = getSearchHandlerWithPort(expectedPort);
                Map<String, Object> searchResult = handler.substringSearch(file.toPath(), pattern);

                assertEquals(expected, searchResult);
            } finally {
                Utils.setInstance(prevUtils);
            }
        }

        private Map<String, Object> buildMatchData(int byteOffset, String beforeString, String afterString,
            String matchString, String logviewerUrlPath) {
            Map<String, Object> match = new HashMap<>();
            match.put("byteOffset", byteOffset);
            match.put("beforeString", beforeString);
            match.put("afterString", afterString);
            match.put("matchString", matchString);
            match.put("logviewerURL", logviewerUrlPrefix + logviewerUrlPath);
            return match;
        }

        private String encodedFileSeparator() {
            return Utils.urlEncodeUtf8(File.separator);
        }
    }

    public static class FindNMatchesTest {

        /**
         * find-n-matches looks through logs properly.
         */
        @Test
        public void testFindNMatches() {
            List<Path> files = new ArrayList<>();
            files.add(new File(String.join(File.separator, "src", "test", "resources"),
                "logviewer-search-context-tests.log.test").toPath());
            files.add(new File(String.join(File.separator, "src", "test", "resources"),
                "logviewer-search-context-tests.log.gz").toPath());

            final LogviewerLogSearchHandler handler = getSearchHandler();

            final List<Map<String, Object>> matches1 = handler.findNMatches(files, 20, 0, 0, "needle").getMatches();
            final List<Map<String, Object>> matches2 = handler.findNMatches(files, 20, 0, 126, "needle").getMatches();
            final List<Map<String, Object>> matches3 = handler.findNMatches(files, 20, 1, 0, "needle").getMatches();

            assertEquals(2, matches1.size());
            assertEquals(4, ((List) matches1.get(0).get("matches")).size());
            assertEquals(4, ((List) matches1.get(1).get("matches")).size());
            assertEquals(String.join(File.separator, "test", "resources", "logviewer-search-context-tests.log.test"), matches1.get(0).get("fileName"));
            assertEquals(String.join(File.separator, "test", "resources", "logviewer-search-context-tests.log.gz"), matches1.get(1).get("fileName"));

            assertEquals(2, ((List) matches2.get(0).get("matches")).size());
            assertEquals(4, ((List) matches2.get(1).get("matches")).size());

            assertEquals(1, matches3.size());
            assertEquals(4, ((List) matches3.get(0).get("matches")).size());
        }
    }

    public static class TestDeepSearchLogs {

        public static final int METRIC_SCANNED_FILES = 0;
        private List<Path> logFiles;
        private Path topoPath;

        /**
         * Setup test environment for each test.
         */
        @BeforeEach
        public void setUp() throws IOException {
            logFiles = new ArrayList<>();
            logFiles.add(Paths.get("src/test/resources/logviewer-search-context-tests.log.test"));
            logFiles.add(Paths.get("src/test/resources/logviewer-search-context-tests.log.gz"));

            topoPath = Files.createTempDirectory("topoA").toAbsolutePath().normalize();
            new File(topoPath.toFile(), "6400").createNewFile();
            new File(topoPath.toFile(), "6500").createNewFile();
            new File(topoPath.toFile(), "6600").createNewFile();
            new File(topoPath.toFile(), "6700").createNewFile();
        }

        /**
         * Clean up test environment.
         */
        @AfterEach
        public void tearDown() {
            if (topoPath != null) {
                try {
                    Utils.forceDelete(topoPath.toString());
                } catch (IOException e) {
                    // ignore...
                }
            }
        }

        @Test
        public void testAllPortsAndSearchArchivedIsTrue() throws IOException {
            LogviewerLogSearchHandler handler = getStubbedSearchHandler();

            handler.deepSearchLogsForTopology("", null, "search", "20", "*", "20", "199", true, null, null);

            ArgumentCaptor<List> files = ArgumentCaptor.forClass(List.class);
            ArgumentCaptor<Integer> numMatches = ArgumentCaptor.forClass(Integer.class);
            ArgumentCaptor<Integer> fileOffset = ArgumentCaptor.forClass(Integer.class);
            ArgumentCaptor<Integer> offset = ArgumentCaptor.forClass(Integer.class);
            ArgumentCaptor<String> search = ArgumentCaptor.forClass(String.class);

            verify(handler, times(4)).findNMatches(files.capture(), numMatches.capture(), fileOffset.capture(),
                offset.capture(), search.capture());
            verify(handler, times(4)).logsForPort(isNull(), any());

            // File offset and byte offset should always be zero when searching multiple workers (multiple ports).
            assertEquals(logFiles, files.getAllValues().get(0));
            assertEquals(Integer.valueOf(20), numMatches.getAllValues().get(0));
            assertEquals(Integer.valueOf(0), fileOffset.getAllValues().get(0));
            assertEquals(Integer.valueOf(0), offset.getAllValues().get(0));
            assertEquals("search", search.getAllValues().get(0));
            assertEquals(logFiles, files.getAllValues().get(0));

            assertEquals(Integer.valueOf(20), numMatches.getAllValues().get(1));
            assertEquals(Integer.valueOf(0), fileOffset.getAllValues().get(1));
            assertEquals(Integer.valueOf(0), offset.getAllValues().get(1));
            assertEquals("search", search.getAllValues().get(1));
            assertEquals(logFiles, files.getAllValues().get(1));

            assertEquals(Integer.valueOf(20), numMatches.getAllValues().get(2));
            assertEquals(Integer.valueOf(0), fileOffset.getAllValues().get(2));
            assertEquals(Integer.valueOf(0), offset.getAllValues().get(2));
            assertEquals("search", search.getAllValues().get(2));
            assertEquals(logFiles, files.getAllValues().get(2));

            assertEquals(Integer.valueOf(20), numMatches.getAllValues().get(3));
            assertEquals(Integer.valueOf(0), fileOffset.getAllValues().get(3));
            assertEquals(Integer.valueOf(0), offset.getAllValues().get(3));
            assertEquals("search", search.getAllValues().get(3));
        }

        @Test
        public void testAllPortsAndSearchArchivedIsFalse() throws IOException {
            LogviewerLogSearchHandler handler = getStubbedSearchHandler();

            handler.deepSearchLogsForTopology("", null, "search", "20", null, "20", "199", false, null, null);

            ArgumentCaptor<List> files = ArgumentCaptor.forClass(List.class);
            ArgumentCaptor<Integer> numMatches = ArgumentCaptor.forClass(Integer.class);
            ArgumentCaptor<Integer> fileOffset = ArgumentCaptor.forClass(Integer.class);
            ArgumentCaptor<Integer> offset = ArgumentCaptor.forClass(Integer.class);
            ArgumentCaptor<String> search = ArgumentCaptor.forClass(String.class);

            verify(handler, times(4)).findNMatches(files.capture(), numMatches.capture(), fileOffset.capture(),
                offset.capture(), search.capture());
            verify(handler, times(4)).logsForPort(isNull(), any());

            // File offset and byte offset should always be zero when searching multiple workers (multiple ports).
            assertEquals(Collections.singletonList(logFiles.get(0)), files.getAllValues().get(0));
            assertEquals(Integer.valueOf(20), numMatches.getAllValues().get(0));
            assertEquals(Integer.valueOf(0), fileOffset.getAllValues().get(0));
            assertEquals(Integer.valueOf(0), offset.getAllValues().get(0));
            assertEquals("search", search.getAllValues().get(0));

            assertEquals(Collections.singletonList(logFiles.get(0)), files.getAllValues().get(1));
            assertEquals(Integer.valueOf(20), numMatches.getAllValues().get(1));
            assertEquals(Integer.valueOf(0), fileOffset.getAllValues().get(1));
            assertEquals(Integer.valueOf(0), offset.getAllValues().get(1));
            assertEquals("search", search.getAllValues().get(1));

            assertEquals(Collections.singletonList(logFiles.get(0)), files.getAllValues().get(2));
            assertEquals(Integer.valueOf(20), numMatches.getAllValues().get(2));
            assertEquals(Integer.valueOf(0), fileOffset.getAllValues().get(2));
            assertEquals(Integer.valueOf(0), offset.getAllValues().get(2));
            assertEquals("search", search.getAllValues().get(2));

            assertEquals(Collections.singletonList(logFiles.get(0)), files.getAllValues().get(3));
            assertEquals(Integer.valueOf(20), numMatches.getAllValues().get(3));
            assertEquals(Integer.valueOf(0), fileOffset.getAllValues().get(3));
            assertEquals(Integer.valueOf(0), offset.getAllValues().get(3));
            assertEquals("search", search.getAllValues().get(3));
        }

        @Test
        public void testOnePortAndSearchArchivedIsTrueAndNotFileOffset() throws IOException {
            LogviewerLogSearchHandler handler = getStubbedSearchHandler();

            handler.deepSearchLogsForTopology("", null, "search", "20", "6700", "0", "0", true, null, null);

            ArgumentCaptor<List> files = ArgumentCaptor.forClass(List.class);
            ArgumentCaptor<Integer> numMatches = ArgumentCaptor.forClass(Integer.class);
            ArgumentCaptor<Integer> fileOffset = ArgumentCaptor.forClass(Integer.class);
            ArgumentCaptor<Integer> offset = ArgumentCaptor.forClass(Integer.class);
            ArgumentCaptor<String> search = ArgumentCaptor.forClass(String.class);

            verify(handler, times(1)).findNMatches(files.capture(), numMatches.capture(), fileOffset.capture(),
                offset.capture(), search.capture());
            verify(handler).logsForPort(isNull(), any());

            assertEquals(logFiles, files.getAllValues().get(0));
            assertEquals(Integer.valueOf(20), numMatches.getAllValues().get(0));
            assertEquals(Integer.valueOf(0), fileOffset.getAllValues().get(0));
            assertEquals(Integer.valueOf(0), offset.getAllValues().get(0));
            assertEquals("search", search.getAllValues().get(0));
        }

        @Test
        public void testOnePortAndSearchArchivedIsTrueAndFileOffsetIs1() throws IOException {
            LogviewerLogSearchHandler handler = getStubbedSearchHandler();

            handler.deepSearchLogsForTopology("", null, "search", "20", "6700", "1", "0", true, null, null);

            ArgumentCaptor<List> files = ArgumentCaptor.forClass(List.class);
            ArgumentCaptor<Integer> numMatches = ArgumentCaptor.forClass(Integer.class);
            ArgumentCaptor<Integer> fileOffset = ArgumentCaptor.forClass(Integer.class);
            ArgumentCaptor<Integer> offset = ArgumentCaptor.forClass(Integer.class);
            ArgumentCaptor<String> search = ArgumentCaptor.forClass(String.class);

            verify(handler, times(1)).findNMatches(files.capture(), numMatches.capture(), fileOffset.capture(),
                offset.capture(), search.capture());
            verify(handler).logsForPort(isNull(), any());

            assertEquals(logFiles, files.getAllValues().get(0));
            assertEquals(Integer.valueOf(20), numMatches.getAllValues().get(0));
            assertEquals(Integer.valueOf(1), fileOffset.getAllValues().get(0));
            assertEquals(Integer.valueOf(0), offset.getAllValues().get(0));
            assertEquals("search", search.getAllValues().get(0));
        }

        @Test
        public void testOnePortAndSearchArchivedIsFalseAndFileOffsetIs1() throws IOException {
            LogviewerLogSearchHandler handler = getStubbedSearchHandler();

            handler.deepSearchLogsForTopology("", null, "search", "20", "6700", "1", "0", false, null, null);

            ArgumentCaptor<List> files = ArgumentCaptor.forClass(List.class);
            ArgumentCaptor<Integer> numMatches = ArgumentCaptor.forClass(Integer.class);
            ArgumentCaptor<Integer> fileOffset = ArgumentCaptor.forClass(Integer.class);
            ArgumentCaptor<Integer> offset = ArgumentCaptor.forClass(Integer.class);
            ArgumentCaptor<String> search = ArgumentCaptor.forClass(String.class);

            verify(handler, times(1)).findNMatches(files.capture(), numMatches.capture(), fileOffset.capture(),
                offset.capture(), search.capture());
            verify(handler).logsForPort(isNull(), any());

            // File offset should be zero, since search-archived is false.
            assertEquals(Collections.singletonList(logFiles.get(0)), files.getAllValues().get(0));
            assertEquals(Integer.valueOf(20), numMatches.getAllValues().get(0));
            assertEquals(Integer.valueOf(0), fileOffset.getAllValues().get(0));
            assertEquals(Integer.valueOf(0), offset.getAllValues().get(0));
            assertEquals("search", search.getAllValues().get(0));
        }

        @Test
        public void testOnePortAndSearchArchivedIsTrueAndFileOffsetIs1AndByteOffsetIs100() throws IOException {
            LogviewerLogSearchHandler handler = getStubbedSearchHandler();

            handler.deepSearchLogsForTopology("", null, "search", "20", "6700", "1", "100", true, null, null);

            verify(handler, times(1)).findNMatches(anyList(), anyInt(), anyInt(), anyInt(), anyString());
            verify(handler, times(1)).logsForPort(isNull(), any());
        }

        @Test
        public void testBadPortAndSearchArchivedIsFalseAndFileOffsetIs1() throws IOException {
            LogviewerLogSearchHandler handler = getStubbedSearchHandler();

            handler.deepSearchLogsForTopology("", null, "search", "20", "2700", "1", "0", false, null, null);

            ArgumentCaptor<List> files = ArgumentCaptor.forClass(List.class);
            ArgumentCaptor<Integer> numMatches = ArgumentCaptor.forClass(Integer.class);
            ArgumentCaptor<Integer> fileOffset = ArgumentCaptor.forClass(Integer.class);
            ArgumentCaptor<Integer> offset = ArgumentCaptor.forClass(Integer.class);
            ArgumentCaptor<String> search = ArgumentCaptor.forClass(String.class);

            // Called with a bad port (not in the config) No searching should be done.
            verify(handler, never()).findNMatches(files.capture(), numMatches.capture(), fileOffset.capture(),
                offset.capture(), search.capture());
            verify(handler, never()).logsForPort(anyString(), any());
        }

        private LogviewerLogSearchHandler getStubbedSearchHandler() {
            Map<String, Object> stormConf = Utils.readStormConfig();
            LogviewerLogSearchHandler handler = new LogviewerLogSearchHandler(stormConf, topoPath, Paths.get(""),
                new ResourceAuthorizer(stormConf), new StormMetricsRegistry());
            handler = spy(handler);

            doReturn(logFiles).when(handler).logsForPort(any(), any());
            doAnswer(invocationOnMock -> {
                Object[] arguments = invocationOnMock.getArguments();
                int fileOffset = (Integer) arguments[2];
                String search = (String) arguments[4];

                return new LogviewerLogSearchHandler.Matched(fileOffset, search, Collections.emptyList(), METRIC_SCANNED_FILES);
            }).when(handler).findNMatches(any(), anyInt(), anyInt(), anyInt(), any());

            return handler;
        }
    }

    private static LogviewerLogSearchHandler getSearchHandler() {
        Map<String, Object> stormConf = Utils.readStormConfig();
        return new LogviewerLogSearchHandler(stormConf, Paths.get(""), Paths.get(""),
            new ResourceAuthorizer(stormConf), new StormMetricsRegistry());
    }

    private static LogviewerLogSearchHandler getSearchHandlerWithPort(int port) {
        Map<String, Object> stormConf = Utils.readStormConfig();
        stormConf.put(DaemonConfig.LOGVIEWER_PORT, port);
        return new LogviewerLogSearchHandler(stormConf, Paths.get(""), Paths.get(""),
            new ResourceAuthorizer(stormConf), new StormMetricsRegistry());
    }

}
