#! /usr/bin/env python3
#
# Copyright 2021 Google Inc.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met: redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer;
# redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution;
# neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

import argparse
import copy
import signal
import sys
import unittest
import unittest.mock

parser = argparse.ArgumentParser(
        description='''Circular buffer for text output.

    To capture the rolling last 25 lines of output from command "command":
    command | logroll.py -n 25

    While that's running, to see the most recent 25 lines of output without
    interrupting "command", send SIGUSR1 to the logroll.py process:
    kill -s USR1 ${PID of logroll.py}''')
parser.add_argument('-n', '--lines', default=10, type=int,
        help='Maximum number of lines to buffer at a time.')
parser.add_argument('file', nargs='?', default=sys.stdin,
        type=argparse.FileType('r', encoding='UTF-8'),
        help='File to read from, default is stdin')

args = parser.parse_args()

def dump_lines(lines, idx):
    for line in lines[idx:]:
        print(line, end='')
    for line in lines[:idx]:
        print(line, end='')

def dump_and_exit(lines, idx):
    dump_lines(lines, idx)
    sys.exit(0)

def main(target, incoming):
    idx = 0
    lines = []
    last_idx = target - 1

    signal.signal(signal.SIGUSR1, lambda num, frame: dump_lines(lines, idx))
    signal.signal(signal.SIGINT, lambda num, frame: dump_and_exit(lines, idx))

    for line in incoming:
        lines.append(line)
        if idx == last_idx:
            idx = 0
            break
        else:
            idx += 1

    for lines[idx] in incoming:
        idx = 0 if idx == last_idx else idx + 1

    dump_lines(lines, idx)

if __name__ == '__main__':
    main(target=args.lines, incoming=args.file)



# Unit tests #

class CopyingMock(unittest.mock.MagicMock):
    def __call__(self, *args, **kwargs):
        args = copy.deepcopy(args)
        kwargs = copy.deepcopy(kwargs)
        return super(CopyingMock, self).__call__(*args, **kwargs)

class TestLogroll(unittest.TestCase):
    # Test data.
    lines2 = ['First line',
              'Second line']
    lines3 = ['First line',
              'Second line',
              'Third line']
    lines8 = ['First line',
              'Second line',
              'Third line',
              'Fourth line',
              'Fifth line',
              'Sixth line',
              'Seventh line',
              'Eigth line']

    # Generator which returns lines like a file object would.
    def line_gen(self, lines):
        for line in lines:
            yield line

    # Generator like above, but which simulates a signal midway through.
    def signal_line_gen(self, lines, pos, sig_dict, signal):
        # Return the first few lines.
        for line in lines[:pos]:
            yield line

        # Simulate receiving the signal.
        self.assertIn(signal, sig_dict)
        if signal in sig_dict:
            # Pas in junk for the num and frame arguments.
            sig_dict[signal](None, None)

        # Return the remaining lines.
        for line in lines[pos:]:
            yield line

    # Set up a mock of signal.signal to record handlers in a dict.
    def mock_signal_dict(self, mock):
        signal_dict = {}
        def signal_signal(num, action):
            signal_dict[num] = action
        mock.side_effect = signal_signal
        return signal_dict

    # Actual test methods.
    def test_filling_dump_lines(self):
        with unittest.mock.patch('builtins.print') as mock_print:
            dump_lines(self.lines2, len(self.lines2))
            calls = list([ unittest.mock.call(line, end='') for
                line in self.lines2 ])
            mock_print.assert_has_calls(calls)

    def test_full_dump_lines(self):
        with unittest.mock.patch('builtins.print') as mock_print:
            dump_lines(self.lines2, 0)
            calls = list([ unittest.mock.call(line, end='') for
                line in self.lines2 ])
            mock_print.assert_has_calls(calls)

    def test_offset_dump_lines(self):
        with unittest.mock.patch('builtins.print') as mock_print:
            dump_lines(self.lines3, 1)
            calls = [ unittest.mock.call(self.lines3[1], end=''),
                      unittest.mock.call(self.lines3[2], end=''),
                      unittest.mock.call(self.lines3[0], end='') ]
            mock_print.assert_has_calls(calls)

    def test_dump_and_exit(self):
        with unittest.mock.patch('sys.exit') as mock_sys_exit, \
             unittest.mock.patch(__name__ + '.dump_lines',
                     new_callable=CopyingMock) as mock_dump_lines:
            idx = 1
            dump_and_exit(self.lines3, idx)
            mock_dump_lines.assert_called_with(self.lines3, idx)
            mock_sys_exit.assert_called_with(0)

    def test_filling_main(self):
        with unittest.mock.patch('builtins.print') as mock_print:
            main(5, self.line_gen(self.lines3))
            calls = list([ unittest.mock.call(line, end='') for
                line in self.lines3 ])
            mock_print.assert_has_calls(calls)

    def test_full_main(self):
        with unittest.mock.patch('builtins.print') as mock_print:
            main(5, self.line_gen(self.lines8))
            calls = list([ unittest.mock.call(line, end='') for
                line in self.lines8[-5:] ])
            mock_print.assert_has_calls(calls)

    def test_sigusr1_filling_main(self):
        with unittest.mock.patch('signal.signal') as mock_signal, \
             unittest.mock.patch(__name__ + '.dump_lines',
                     new_callable=CopyingMock) as mock_dump_lines:

            signal_dict = self.mock_signal_dict(mock_signal)

            main(4, self.signal_line_gen(
                self.lines8, 3, signal_dict, signal.SIGUSR1))

            mock_dump_lines.assert_has_calls([
                unittest.mock.call(self.lines8[0:3], 3 % 4),
                unittest.mock.call(self.lines8[-4:], len(self.lines8) % 4)
            ])

    def test_sigint_filling_main(self):
        with unittest.mock.patch('signal.signal') as mock_signal, \
             unittest.mock.patch(__name__ + '.dump_lines',
                     new_callable=CopyingMock) as mock_dump_lines:

            signal_dict = self.mock_signal_dict(mock_signal)

            with self.assertRaises(SystemExit):
                main(4, self.signal_line_gen(
                    self.lines8, 3, signal_dict, signal.SIGINT))

            mock_dump_lines.assert_has_calls([
                unittest.mock.call(self.lines8[0:3], 3 % 4),
            ])

    def test_sigusr1_full_main(self):
        with unittest.mock.patch('signal.signal') as mock_signal, \
             unittest.mock.patch(__name__ + '.dump_lines',
                     new_callable=CopyingMock) as mock_dump_lines:

            signal_dict = self.mock_signal_dict(mock_signal)

            main(4, self.signal_line_gen(
                self.lines8, 5, signal_dict, signal.SIGUSR1))

            mock_dump_lines.assert_has_calls([
                unittest.mock.call(self.lines8[4:5] + self.lines8[1:4], 5 % 4),
                unittest.mock.call(self.lines8[-4:], len(self.lines8) % 4)
            ])

    def test_sigint_full_main(self):
        with unittest.mock.patch('signal.signal') as mock_signal, \
             unittest.mock.patch(__name__ + '.dump_lines',
                     new_callable=CopyingMock) as mock_dump_lines:

            signal_dict = self.mock_signal_dict(mock_signal)

            with self.assertRaises(SystemExit):
                main(4, self.signal_line_gen(
                    self.lines8, 5, signal_dict, signal.SIGINT))

            mock_dump_lines.assert_has_calls([
                unittest.mock.call(self.lines8[4:5] + self.lines8[1:4], 5 % 4),
            ])
