# Copyright lowRISC contributors.
# Licensed under the Apache License, Version 2.0, see LICENSE for details.
# SPDX-License-Identifier: Apache-2.0

import unittest
from typing import Tuple
from unittest.mock import MagicMock, call

from bitstream_bisect import (BisectLog, BisectSession, CommitHash,
                              CommitJudgment, GitControllerABC)


class MockableGitController(GitControllerABC):
    """Provides stub implementations of GitControllerABC's abstract methods."""

    def _git(self, *args: str) -> Tuple[int, str, str]:
        raise Exception("_git unexpectedly called")

    def _git_no_capture_output(self, *args: str) -> int:
        returncode, _, _ = self._git(*args)
        return returncode


class TestCommitHash(unittest.TestCase):
    """Test that CommitHash validates its inputs."""

    def test_simple(self):
        c = CommitHash("c1")
        self.assertEqual(str(c), "c1")

        c = CommitHash("deadbeef")
        self.assertEqual(str(c), "deadbeef")
        self.assertEqual(str(c), CommitHash("deadbeef"))

    def test_non_lowercase(self):
        c = CommitHash("C1")
        self.assertEqual(str(c), "c1")
        self.assertEqual(str(c), CommitHash("C1"))
        self.assertEqual(str(c), CommitHash("c1"))

        c = CommitHash("DEADBEEF")
        self.assertEqual(str(c), "deadbeef")
        self.assertEqual(str(c), CommitHash("DEADBEEF"))
        self.assertEqual(str(c), CommitHash("deadbeef"))

        c = CommitHash("DeAdBeEf")
        self.assertEqual(str(c), "deadbeef")
        self.assertEqual(str(c), CommitHash("DeAdBeEf"))
        self.assertEqual(str(c), CommitHash("deadbeef"))

    def test_invalid(self):
        with self.assertRaises(Exception):
            CommitHash("")
        with self.assertRaises(Exception):
            CommitHash("hello")
        with self.assertRaises(Exception):
            CommitHash("HEAD")


class TestParseBisectLog(unittest.TestCase):

    def test_one_line(self):
        bisect_log = BisectLog("git bisect good c123")
        self.assertEqual(bisect_log.judgments, [('c123', CommitJudgment.GOOD)])
        self.assertEqual(bisect_log.candidates, [])

        bisect_log = BisectLog("git bisect bad c123")
        self.assertEqual(bisect_log.judgments, [('c123', CommitJudgment.BAD)])
        self.assertEqual(bisect_log.candidates, [])

        bisect_log = BisectLog("git bisect skip c123")
        self.assertEqual(bisect_log.judgments, [('c123', CommitJudgment.SKIP)])
        self.assertEqual(bisect_log.candidates, [])

        bisect_log = BisectLog("")
        self.assertEqual(bisect_log.judgments, [])
        self.assertEqual(bisect_log.candidates, [])

        bisect_log = BisectLog("git bisect start")
        self.assertEqual(bisect_log.judgments, [])
        self.assertEqual(bisect_log.candidates, [])

    def test_unrecognized_actions_rejected(self):
        """The parser should reject non-comment lines it doesn't understand."""
        # Unexpected whitespace.
        with self.assertRaises(BisectLog.ParserBug):
            _ = BisectLog(" git bisect skip c123")
        with self.assertRaises(BisectLog.ParserBug):
            _ = BisectLog("git  bisect skip c123")
        with self.assertRaises(BisectLog.ParserBug):
            _ = BisectLog("git bisect  skip c123")
        with self.assertRaises(BisectLog.ParserBug):
            _ = BisectLog(" # git bisect  skip c123")
        # Unrecognized action with commit.
        with self.assertRaises(BisectLog.ParserBug):
            _ = BisectLog("git bisect foo c123")
        # Unrecognized action without commit.
        with self.assertRaises(BisectLog.ParserBug):
            _ = BisectLog("git bisect bar")

    def test_unrecognized_comments_ignored(self):
        bisect_log = BisectLog("# foo")
        self.assertEqual(bisect_log.judgments, [])
        self.assertEqual(bisect_log.candidates, [])

    def test_candidates_simple(self):
        bisect_log = BisectLog("")
        self.assertEqual(bisect_log.judgments, [])
        self.assertEqual(bisect_log.candidates, [])

        bisect_log = BisectLog(
            "# first bad commit: [c42] Commit description...")
        self.assertEqual(bisect_log.judgments, [])
        self.assertEqual(bisect_log.candidates, ["c42"])

        bisect_log = BisectLog("""
# only skipped commits left to test
# possible first bad commit: [c123] Foo description
# possible first bad commit: [c234] Bar description
""")
        self.assertEqual(bisect_log.judgments, [])
        self.assertEqual(bisect_log.candidates, ["c123", "c234"])

    def test_realistic_only_skipped_remain(self):
        # This output was produced by `git` on a temporary repo.
        BISECT_LOG = """# status: waiting for both good and bad commits
# good: [0b47ff5f2ac78589cab7d9b93c4cabb9e4a1f736] First commit
git bisect good 0b47ff5f2ac78589cab7d9b93c4cabb9e4a1f736
# status: waiting for bad commit, 1 good commit known
# bad: [4f166216806f44b1ddf67c4649d5e646cf9bbf22] Fourth commit
git bisect bad 4f166216806f44b1ddf67c4649d5e646cf9bbf22
# skip: [132c4e1f8773cd3e14e2f4de8a8b77422b74e28b] Third commit
git bisect skip 132c4e1f8773cd3e14e2f4de8a8b77422b74e28b
# skip: [8fe76ab59c4f7d43237292a57f1d84b774436224] Second commit
git bisect skip 8fe76ab59c4f7d43237292a57f1d84b774436224
# only skipped commits left to test
# possible first bad commit: [4f166216806f44b1ddf67c4649d5e646cf9bbf22] Fourth commit
# possible first bad commit: [132c4e1f8773cd3e14e2f4de8a8b77422b74e28b] Third commit
# possible first bad commit: [8fe76ab59c4f7d43237292a57f1d84b774436224] Second commit
# good: [8fe76ab59c4f7d43237292a57f1d84b774436224] Second commit
git bisect good 8fe76ab59c4f7d43237292a57f1d84b774436224
# only skipped commits left to test
# possible first bad commit: [4f166216806f44b1ddf67c4649d5e646cf9bbf22] Fourth commit
# possible first bad commit: [132c4e1f8773cd3e14e2f4de8a8b77422b74e28b] Third commit
"""
        bisect_log = BisectLog(BISECT_LOG)
        self.assertEqual(bisect_log.candidates, [
            "4f166216806f44b1ddf67c4649d5e646cf9bbf22",
            "132c4e1f8773cd3e14e2f4de8a8b77422b74e28b"
        ])
        self.assertEqual(bisect_log.judgments, [
            ("0b47ff5f2ac78589cab7d9b93c4cabb9e4a1f736", CommitJudgment.GOOD),
            ("4f166216806f44b1ddf67c4649d5e646cf9bbf22", CommitJudgment.BAD),
            ("132c4e1f8773cd3e14e2f4de8a8b77422b74e28b", CommitJudgment.SKIP),
            ("8fe76ab59c4f7d43237292a57f1d84b774436224", CommitJudgment.SKIP),
            ("8fe76ab59c4f7d43237292a57f1d84b774436224", CommitJudgment.GOOD),
        ])

    def test_realistic_has_first_bad_commit(self):
        # This output was produced by `git` on a temporary repo.
        BISECT_LOG = """git bisect start
# status: waiting for both good and bad commits
# good: [00722da469712cd917e59082601d189fe0c6507e] First commit
git bisect good 00722da469712cd917e59082601d189fe0c6507e
# status: waiting for bad commit, 1 good commit known
# bad: [3e2031c1cb7b27e8bf36c85756c301279b8f9f30] Fourth commit
git bisect bad 3e2031c1cb7b27e8bf36c85756c301279b8f9f30
# bad: [dc5320f6d974d3fce7bcc03fd18d920c8b5e3cbf] Third commit
git bisect bad dc5320f6d974d3fce7bcc03fd18d920c8b5e3cbf
# bad: [d72b7491e6fdadb2ef742370410d5488a7bbfdba] Second commit
git bisect bad d72b7491e6fdadb2ef742370410d5488a7bbfdba
# first bad commit: [d72b7491e6fdadb2ef742370410d5488a7bbfdba] Second commit
"""
        bisect_log = BisectLog(BISECT_LOG)
        self.assertEqual(bisect_log.candidates,
                         ["d72b7491e6fdadb2ef742370410d5488a7bbfdba"])
        self.assertEqual(bisect_log.judgments, [
            ("00722da469712cd917e59082601d189fe0c6507e", CommitJudgment.GOOD),
            ("3e2031c1cb7b27e8bf36c85756c301279b8f9f30", CommitJudgment.BAD),
            ("dc5320f6d974d3fce7bcc03fd18d920c8b5e3cbf", CommitJudgment.BAD),
            ("d72b7491e6fdadb2ef742370410d5488a7bbfdba", CommitJudgment.BAD),
        ])


class TestBisectSession(unittest.TestCase):

    def test_two_cached_only_fast(self):
        """Simple case with two cached commits and only the fast command."""

        BISECT_LOG = """git bisect start
# status: waiting for both good and bad commits
# good: [c1] Fake good commit
git bisect good c1
# status: waiting for bad commit, 1 good commit known
# bad: [c2] Fake bad commit
git bisect bad c2
# first bad commit: [c2] Fake bad commit
"""

        git = MockableGitController()
        git._git = MagicMock(return_value=(0, "", ""))
        git.bisect_view = MagicMock(return_value=[
            (CommitHash("c1"), "Commit description"),
            (CommitHash("c2"), "Commit description"),
        ])
        git.bisect_log = MagicMock(return_value=BisectLog(BISECT_LOG))

        session = BisectSession(git, cache_keys=set(["c1", "c2"]))
        result: BisectLog = session.run("c1", "c2", ["fast_script.sh"])

        self.assertEqual(result.candidates, ["c2"])
        git._git.assert_has_calls([
            call("bisect", "start"),
            call("bisect", "good", "c1"),
            call("bisect", "bad", "c2"),
            call('bisect', 'run', 'fast_script.sh'),
            # We would expect `call('bisect', 'log')` here, but mocking
            # `git.bisect_log()` prevented it from calling `git._git()`.
            call('bisect', 'reset'),
        ])
        git.bisect_log.assert_called_once_with()
        git.bisect_view.assert_called_once_with()

    def test_only_fast(self):
        """Mix of cached and uncached with only the fast command."""

        BISECT_LOG = """git bisect start
# status: waiting for both good and bad commits
# skip: [c2] Skipped commit
git bisect skip c2
# good: [c1] Fake good commit
git bisect good c1
# status: waiting for bad commit, 1 good commit known
# bad: [c3] Fake bad commit
git bisect bad c3
# only skipped commits left to test
# possible first bad commit: [c2] Skipped commit
# possible first bad commit: [c3] Fake bad commit
"""

        git = MockableGitController()
        git._git = MagicMock(return_value=(0, "", ""))
        git.bisect_view = MagicMock(return_value=[
            (CommitHash("c1"), "Fake good commit"),
            (CommitHash("c2"), "Skipped commit"),
            (CommitHash("c3"), "Fake bad commit"),
        ])

        git.bisect_log = MagicMock(return_value=BisectLog(BISECT_LOG))

        session = BisectSession(git, cache_keys=set(["c1", "c3"]))
        bisect_log = session.run("c1", "c3", ["fast_script.sh"])

        self.assertEqual(bisect_log.candidates, ["c2", "c3"])
        git._git.assert_has_calls([
            call("bisect", "start"),
            call("bisect", "good", "c1"),
            call("bisect", "bad", "c3"),
            call("bisect", "skip", "c2"),
            call('bisect', 'run', 'fast_script.sh'),
            # We would expect `call('bisect', 'log')` here, but mocking
            # `git.bisect_log()` prevented it from calling `git._git()`.
        ])
        git.bisect_log.assert_called_once_with()
        git.bisect_view.assert_called_once_with()

    def test_fast_and_slow(self):
        """Mix of cached and uncached with fast and slow command."""

        BISECT_LOG_1 = """git bisect start
# status: waiting for both good and bad commits
# good: [c1] Commit 1
git bisect good c1
# status: waiting for bad commit, 1 good commit known
# bad: [c5] Commit 5
git bisect bad c5
# skip: [c3] Commit 3
git bisect skip c3
# skip: [c4] Commit 4
git bisect skip c4
# good: [c2] Commit 2
git bisect good c2
# only skipped commits left to test
# possible first bad commit: [c3] Commit 3
# possible first bad commit: [c4] Commit 4
"""
        BISECT_LOG_2 = """git bisect start
# status: waiting for both good and bad commits
# good: [c1] Commit 1
git bisect good c1
# status: waiting for bad commit, 1 good commit known
# bad: [c5] Commit 5
git bisect bad c5
# good: [c2] Commit 2
git bisect good c2
# good: [c3] Commit 3
git bisect good c3
# bad: [c4] Commit 4
git bisect bad c4
# first bad commit: [c4] Commit 4
"""
        parsed_bisect_logs = [BisectLog(BISECT_LOG_1), BisectLog(BISECT_LOG_2)]

        git = MockableGitController()
        git._git = MagicMock(return_value=(0, "", ""))
        git.bisect_log = MagicMock(side_effect=parsed_bisect_logs)

        git.bisect_view = MagicMock(return_value=[
            (CommitHash("c1"), "Commit 1"),
            (CommitHash("c2"), "Commit 2"),
            (CommitHash("c3"), "Commit 3"),
            (CommitHash("c4"), "Commit 4"),
            (CommitHash("c5"), "Commit 5"),
        ])

        session = BisectSession(git, cache_keys=set(["c1", "c2"]))
        bisect_log = session.run("c1", "c5", ["fast_script.sh"],
                                 ["slow_script.sh"])

        self.assertEqual(bisect_log.candidates, ["c4"])

        git._git.assert_has_calls([
            call("bisect", "start"),
            call("bisect", "good", "c1"),
            call("bisect", "bad", "c5"),
            call("bisect", "skip", "c3", "c4", "c5"),
            call('bisect', 'run', 'fast_script.sh'),
            call('bisect', 'reset'),
            call('bisect', 'start'),
            call('bisect', 'good', 'c1'),
            call('bisect', 'bad', 'c5'),
            call('bisect', 'good', 'c2'),
            call('bisect', 'run', 'slow_script.sh'),
        ])
        git.bisect_log.assert_has_calls([call(), call()])
        git.bisect_view.assert_called_once_with()


if __name__ == '__main__':
    unittest.main()
