# -*- coding: utf-8 -*-
"""
Tests for the debug client.
"""
import socket
import pytest
import json
import os.path
import mu.debugger.client
from unittest import mock
from PyQt5.QtCore import pyqtBoundSignal


def test_Breakpoint_init():
    """
    Ensure the Breakpoint class is initialised as expected.
    """
    bp = mu.debugger.client.Breakpoint(1, 'filename.py', 10)
    assert bp.bpnum == 1
    assert bp.filename == 'filename.py'
    assert bp.line == 10
    assert bp.enabled is True
    assert bp.temporary is False
    assert bp.funcname is None


def test_Breakpoint_str():
    """
    Ensure a string representation of a Breakpoint is meaningful.
    """
    bp = mu.debugger.client.Breakpoint(1, 'filename.py', 10)
    assert str(bp) == 'filename.py:10'


def test_CommandBufferHandler_init():
    """
    Check the CommandBufferHandler initialises as expected: with two signals
    and a reference to the debugger client instance.
    """
    mock_debugger = mock.MagicMock()
    cbh = mu.debugger.client.CommandBufferHandler(mock_debugger)
    assert isinstance(cbh.on_command, pyqtBoundSignal)
    assert isinstance(cbh.on_fail, pyqtBoundSignal)
    assert cbh.debugger == mock_debugger


def test_CommandBufferHandler_worker_with_connection_refused_error():
    """
    Check that the connection will try up to 10 times before emitting an
    on_fail signal.
    """
    mock_debugger = mock.MagicMock()
    mock_debugger.host = 'localhost'
    mock_debugger.port = 9999
    mock_socket_factory = mock.MagicMock()
    mock_socket = mock.MagicMock()
    mock_socket.connect.side_effect = ConnectionRefusedError()
    mock_socket_factory.socket.return_value = mock_socket
    mock_time = mock.MagicMock()
    mock_debugger = mock.MagicMock()
    cbh = mu.debugger.client.CommandBufferHandler(mock_debugger)
    cbh.on_fail = mock.MagicMock()
    with mock.patch('mu.debugger.client.socket', mock_socket_factory), \
            mock.patch('mu.debugger.client.time', mock_time):
        cbh.worker()
    msg = ('Connection timed out. Is your machine slow or busy? Free up some '
           "of the machine's resources and try again.")
    cbh.on_fail.emit.assert_called_once_with(msg)
    assert mock_socket.connect.call_count == 50
    assert mock_time.sleep.call_count == 49


def test_CommandBufferHandler_worker_with_address_error():
    """
    Check that the connection will try up to 10 times before emitting an
    on_fail signal.
    """
    mock_debugger = mock.MagicMock()
    mock_debugger.host = 'localhost'
    mock_debugger.port = 9999
    mock_socket_factory = mock.MagicMock()
    mock_socket = mock.MagicMock()
    mock_socket.connect.side_effect = OSError()
    mock_socket_factory.socket.return_value = mock_socket
    mock_debugger = mock.MagicMock()
    cbh = mu.debugger.client.CommandBufferHandler(mock_debugger)
    cbh.on_fail = mock.MagicMock()
    with mock.patch('mu.debugger.client.socket', mock_socket_factory):
        cbh.worker()
    msg = ('Could not find localhost.\n'
           "Ensure you have '127.0.0.1 localhost' in your /etc/hosts file.")
    cbh.on_fail.emit.assert_called_once_with(msg)


def test_CommandBufferHandler_worker_break_loop():
    """
    Ensure if the worker receives None from the socket then break out of the
    loop to end the thread.
    """
    mock_debugger = mock.MagicMock()
    mock_debugger.host = 'localhost'
    mock_debugger.port = 9999
    mock_socket_factory = mock.MagicMock()
    mock_socket = mock.MagicMock()
    mock_socket.recv.return_value = None
    mock_socket_factory.socket.return_value = mock_socket
    cbh = mu.debugger.client.CommandBufferHandler(mock_debugger)
    with mock.patch('mu.debugger.client.socket', mock_socket_factory):
        cbh.worker()
    mock_socket.recv.assert_called_once_with(1024)


def test_CommandBufferHandler_worker_exception_breaks_loop():
    """
    Ensure if the worker has an exception thrown from the socket then break out
    of the loop to end the thread.
    """
    mock_debugger = mock.MagicMock()
    mock_debugger.host = 'localhost'
    mock_debugger.port = 9999
    mock_socket_factory = mock.MagicMock()
    mock_socket = mock.MagicMock()
    mock_socket.recv.side_effect = Exception('Bang!')
    mock_socket_factory.socket.return_value = mock_socket
    cbh = mu.debugger.client.CommandBufferHandler(mock_debugger)
    with mock.patch('mu.debugger.client.socket', mock_socket_factory):
        cbh.worker()
    mock_socket.recv.assert_called_once_with(1024)
    assert cbh.stopped


def test_command_buffer_message():
    """
    Make sure that the command buffer of bytes received with a terminated
    message results in the expected command, associated arguments and the
    remainder is correctly populated.
    """
    msg = json.dumps(["bootstrap", {'arg': 'value'}]).encode('utf-8')
    msg += mu.debugger.client.Debugger.ETX
    # Splitting the message in two ensures remainder handling is exercised.
    pos = len(msg) // 2
    msg1 = msg[:pos]
    msg2 = msg[pos:]
    mock_debugger = mock.MagicMock()
    mock_debugger.ETX = mu.debugger.client.Debugger.ETX
    mock_debugger.host = 'localhost'
    mock_debugger.port = 9999
    mock_socket_factory = mock.MagicMock()
    mock_socket = mock.MagicMock()
    mock_socket_factory.socket.return_value = mock_socket
    mock_socket.recv.side_effect = [msg1, msg2, None]
    cbh = mu.debugger.client.CommandBufferHandler(mock_debugger)
    cbh.on_command = mock.MagicMock()
    with mock.patch('mu.debugger.client.socket', mock_socket_factory):
        cbh.worker()
    assert mock_debugger.socket.recv.call_count == 3
    expected = msg.replace(mu.debugger.client.Debugger.ETX,
                           b'').decode('utf-8')
    cbh.on_command.emit.assert_called_once_with(expected)


def test_Debugger_init():
    """
    Ensure a the Debugger client class initialises properly.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    assert db.host == 'localhost'
    assert db.port == 1908
    assert db.proc is None
    assert db.view is None


def test_Debugger_start():
    """
    Ensure the Debugger client starts a session correctly.
    """
    mock_thread_instance = mock.MagicMock()
    mock_thread = mock.MagicMock(return_value=mock_thread_instance)
    mock_handler_instance = mock.MagicMock()
    mock_handler = mock.MagicMock(return_value=mock_handler_instance)
    with mock.patch('mu.debugger.client.QThread', mock_thread), \
            mock.patch('mu.debugger.client.CommandBufferHandler',
                       mock_handler):
        db = mu.debugger.client.Debugger('localhost', 1908)
        db.view = mock.MagicMock()
        db.start()
    mock_thread.assert_called_once_with(db.view.view)
    mock_thread_instance.started.connect.assert_called_once_with(
        mock_handler_instance.worker)
    mock_thread_instance.start.assert_called_once_with()
    mock_handler.assert_called_once_with(db)
    mock_handler_instance.moveToThread.\
        assert_called_once_with(mock_thread_instance)
    mock_handler_instance.on_command.connect.\
        assert_called_once_with(db.on_command)
    mock_handler_instance.on_fail.connect.assert_called_once_with(db.on_fail)


def test_Debugger_on_command():
    """
    Ensure the debug client handles command messages sent from the
    CommandBufferHandler thread.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.on_bootstrap = mock.MagicMock()
    msg = json.dumps(["bootstrap", {'arg': 'value'}])
    db.on_command(msg)
    db.on_bootstrap.assert_called_once_with(arg='value')


def test_Debugger_on_fail():
    """
    If a failure is emitted ensure it's logged.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.view = mock.MagicMock()
    with mock.patch('mu.debugger.client.logger.error') as mock_log:
        db.on_fail('bang')
        mock_log.assert_called_once_with('bang')
        db.view.debug_on_fail.assert_called_once_with('bang')


def test_Debugger_stop():
    """
    Ensure the debugger client stops gracefully.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.proc = mock.MagicMock()
    db.output = mock.MagicMock()
    db.socket = mock.MagicMock()
    db.command_handler = mock.MagicMock()
    db.listener_thread = mock.MagicMock()
    db.stop()
    assert db.command_handler.stopped is True
    db.listener_thread.quit.assert_called_once_with()
    db.listener_thread.wait.assert_called_once_with()
    db.proc.wait.assert_called_once_with()
    db.socket.shutdown.assert_called_once_with(socket.SHUT_WR)


def test_Debugger_output():
    """
    Ensure the "good case" call for output works (there appears to be a working
    socket connection).
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.socket = mock.MagicMock()
    db.output('test', foo='bar')
    db.socket.sendall.assert_called_once_with(b'["test", {"foo": "bar"}]\x03')


def test_Debugger_output_client_error():
    """
    Ensure that any debug client error is logged.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.socket = mock.MagicMock()
    db.socket.sendall.side_effect = OSError('bang!')
    with mock.patch('mu.debugger.client.logger.debug') as mock_logger:
        db.output('test', foo='bar')
        assert mock_logger.call_count == 2
        mock_logger.call_args_list[0][0] == 'Debugger client error.'
        mock_logger.call_args_list[1][0] == OSError('bang!')


def test_Debugger_output_no_client_connection():
    """
    Ensure that if output is attempted via a disconnected client, it's logged
    for further information.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.socket = mock.MagicMock()
    db.socket.sendall.side_effect = AttributeError('bang!')
    with mock.patch('mu.debugger.client.logger.debug') as mock_logger:
        db.output('test', foo='bar')
        assert mock_logger.call_count == 2
        mock_logger.call_args_list[0][0] == ('Debugger client not connected '
                                             'to runner.')
        mock_logger.call_args_list[1][0] == AttributeError('bang!')


def test_Debugger_breakpoint_as_tuple():
    """
    Given a tuple of (filename, line), check the breakpoint method returns an
    object representing the referenced breakpoint.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    mock_breakpoint = mock.MagicMock()
    key = os.path.normcase(os.path.abspath('file.py'))
    db.bp_index = {
        key: {
            666: mock_breakpoint,
        }
    }
    assert db.breakpoint(('file.py', 666)) == mock_breakpoint


def test_Debugger_breakpoint_as_breakpoint_number():
    """
    Given a breakpoint number, check the breakpoint method returns an object
    representing the referenced breakpoint.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    mock_breakpoint = mock.MagicMock()
    db.bp_list = {
        123: mock_breakpoint,
    }
    assert db.breakpoint(123) == mock_breakpoint


def test_Debugger_breakpoint_unknown():
    """
    Ensure the breakpoint method raises an UnknownBreakpoint exception if the
    referenced breakpoint is, you've guessed it, unknown.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    mock_breakpoint = mock.MagicMock()
    db.bp_list = {
        123: mock_breakpoint,
    }
    with pytest.raises(mu.debugger.client.UnknownBreakpoint):
        db.breakpoint(321)


def test_Debugger_breakpoints():
    """
    Given a filename, ensure all the related breakpoints are returned.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    mock_breakpoint = mock.MagicMock()
    key = os.path.normcase(os.path.abspath('file.py'))
    db.bp_index = {
        key: {
            666: mock_breakpoint,
        }
    }
    assert db.breakpoints('file.py') == {666: mock_breakpoint, }


def test_Debugger_create_breakpoint():
    """
    Ensure creating a new breakpoint results in the expected output call to the
    debug runner.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.output = mock.MagicMock()
    db.create_breakpoint('file.py', 123)
    db.output.assert_called_once_with('break', filename='file.py', line=123,
                                      temporary=False)


def test_Debugger_enable_breakpoint():
    """
    Ensure enabling a breakpoint results in the expected output call to the
    debug runner.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.output = mock.MagicMock()
    mock_breakpoint = mock.MagicMock()
    mock_breakpoint.bpnum = 123
    db.enable_breakpoint(mock_breakpoint)
    db.output.assert_called_once_with('enable', bpnum=123)


def test_Debugger_disable_breakpoint():
    """
    Ensure disabling a breakpoint results in the expected output call to the
    debug runner.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.output = mock.MagicMock()
    mock_breakpoint = mock.MagicMock()
    mock_breakpoint.bpnum = 123
    db.disable_breakpoint(mock_breakpoint)
    db.output.assert_called_once_with('disable', bpnum=123)


def test_Debugger_ignore_breakpoint():
    """
    Ensure ignoring a breakpoint for "count" iterations results in the expected
    output call to the debug runner.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.output = mock.MagicMock()
    mock_breakpoint = mock.MagicMock()
    mock_breakpoint.bpnum = 123
    db.ignore_breakpoint(mock_breakpoint, 4)
    db.output.assert_called_once_with('ignore', bpnum=123, count=4)


def test_Debugger_clear_breakpoint():
    """
    Ensure clearing a breakpoint results in the expected output call to the
    debug runner.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.output = mock.MagicMock()
    mock_breakpoint = mock.MagicMock()
    mock_breakpoint.bpnum = 123
    db.clear_breakpoint(mock_breakpoint)
    db.output.assert_called_once_with('clear', bpnum=123)


def test_Debugger_do_run():
    """
    Ensure instructing the client to run to the next breakpoint results in the
    expected output call to the debug runner.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.output = mock.MagicMock()
    db.do_run()
    db.output.assert_called_once_with('continue')


def test_Debugger_do_step():
    """
    Ensure instructing the client to step through one stack frame results in
    the expected output call to the debug runner.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.output = mock.MagicMock()
    db.do_step()
    db.output.assert_called_once_with('step')


def test_Debugger_do_next():
    """
    Ensure instructing the client to go to the next line in the stack frame
    results in the expected output call to the debug runner.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.output = mock.MagicMock()
    db.do_next()
    db.output.assert_called_once_with('next')


def test_Debugger_do_return():
    """
    Ensure instructing the client to return to the previous the stack frame
    results in the expected output call to the debug runner.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.output = mock.MagicMock()
    db.do_return()
    db.output.assert_called_once_with('return')


def test_Debugger_on_bootstrap():
    """
    Test the debug client responds correctly to a signal from the runner that
    it has finished setting up. Essentially this just means passing on details
    of breakpoints to on_breakpoint create.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.view = mock.MagicMock()
    db.on_breakpoint_create = mock.MagicMock()
    breakpoints = [
        {
            'name': 'breakpoint1',
        },
        {
            'name': 'breakpoint2',
        },
    ]
    db.on_bootstrap(breakpoints)
    assert db.bp_index == {}
    assert db.bp_list == [True, ]
    assert db.on_breakpoint_create.call_count == 2
    db.view.debug_on_bootstrap.assert_called_once_with()


def test_Debugger_on_breakpoint_create():
    """
    Test the debug client handles the signal that a breakpoint is created in
    the expected manner, by instantiating a Breakpoint instance and adding it
    to the bp_index and bp_list datastructures before signalling to the view
    to display a visual representation of the breakpoint.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.view = mock.MagicMock()
    db.on_bootstrap([])
    data = {
        'bpnum': 1,
        'filename': 'file.py',
        'line': 10,
    }
    db.on_breakpoint_create(**data)
    bp = db.bp_index[os.path.normcase(os.path.abspath('file.py'))][10]
    assert bp.bpnum == 1
    assert bp.filename == 'file.py'
    assert bp.line == 10
    assert bp.enabled is True
    assert bp.temporary is False
    assert bp.funcname is None
    assert bp in db.bp_list
    db.view.debug_on_breakpoint_enable.assert_called_once_with(bp)


def test_Debugger_on_breakpoint_create_disabled():
    """
    As above, but the runner has signalled the breakpoint exists but is
    disabled.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.view = mock.MagicMock()
    db.on_bootstrap([])
    data = {
        'bpnum': 1,
        'filename': 'file.py',
        'line': 10,
        'enabled': False,
    }
    db.on_breakpoint_create(**data)
    bp = db.bp_index[os.path.normcase(os.path.abspath('file.py'))][10]
    assert bp.enabled is False
    db.view.debug_on_breakpoint_disable.assert_called_once_with(bp)


def test_Debugger_on_breakpoint_enable():
    """
    Handle when the runner signals that a breakpoint has transitioned to
    enabled.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.view = mock.MagicMock()
    db.on_bootstrap([])
    data = {
        'bpnum': 1,
        'filename': 'file.py',
        'line': 10,
        'enabled': False,
    }
    db.on_breakpoint_create(**data)
    db.view.reset_mock()
    db.on_breakpoint_enable(1)
    bp = db.bp_index[os.path.normcase(os.path.abspath('file.py'))][10]
    assert bp.enabled is True
    db.view.debug_on_breakpoint_enable.assert_called_once_with(bp)


def test_Debugger_on_breakpoint_disable():
    """
    Handle when the runner signals that a breakpoint has transitioned to
    disabled.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.view = mock.MagicMock()
    db.on_bootstrap([])
    data = {
        'bpnum': 1,
        'filename': os.path.normcase(os.path.abspath('file.py')),
        'line': 10,
    }
    db.on_breakpoint_create(**data)
    db.view.reset_mock()
    db.on_breakpoint_disable(1)
    bp = db.bp_index[os.path.normcase(os.path.abspath('file.py'))][10]
    assert bp.enabled is False
    db.view.debug_on_breakpoint_disable.assert_called_once_with(bp)


def test_Debugger_on_breakpoint_ignore():
    """
    Handle when the runner signals that it will ignore a breakpoint "count"
    iterations.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.view = mock.MagicMock()
    db.on_bootstrap([])
    data = {
        'bpnum': 1,
        'filename': 'file.py',
        'line': 10,
    }
    db.on_breakpoint_create(**data)
    db.view.reset_mock()
    db.on_breakpoint_ignore(1, 5)
    bp = db.bp_index[os.path.normcase(os.path.abspath('file.py'))][10]
    assert bp.ignore == 5
    db.view.debug_on_breakpoint_ignore.assert_called_once_with(bp, 5)


def test_Debugger_on_breakpoint_clear():
    """
    Ensure handling of clearing a breakpoint is passed to the view.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.view = mock.MagicMock()
    db.on_bootstrap([])
    data = {
        'bpnum': 1,
        'filename': 'file.py',
        'line': 10,
    }
    db.on_breakpoint_create(**data)
    db.view.reset_mock()
    db.on_breakpoint_clear(1)
    bp = db.bp_index[os.path.normcase(os.path.abspath('file.py'))][10]
    db.view.debug_on_breakpoint_clear.assert_called_once_with(bp)


def test_Debugger_on_stack():
    """
    Handle the runner sending revised data about the current state of the debug
    stack.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.view = mock.MagicMock()
    stack = mock.MagicMock()
    db.on_stack(stack)
    assert db.stack == stack
    db.view.debug_on_stack.assert_called_once_with(stack)


def test_Debugger_on_restart():
    """
    On restart is passed to the view.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.view = mock.MagicMock()
    db.on_restart()
    db.view.debug_on_restart.assert_called_once_with()


def test_Debugger_on_call():
    """
    On call is passed to the view with the calling args.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.view = mock.MagicMock()
    db.on_call(args={'foo': 'bar'})
    db.view.debug_on_call.assert_called_once_with({'foo': 'bar'})


def test_Debugger_on_return():
    """
    On return is passed to the view with the return value from the function.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.view = mock.MagicMock()
    db.on_return(True)
    db.view.debug_on_return.assert_called_once_with(True)


def test_Debugger_on_line():
    """
    Ensure details of the runner moving to the referenced file and line are
    passed to the view.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.view = mock.MagicMock()
    db.on_line('file.py', 10)
    db.view.debug_on_line.assert_called_once_with('file.py', 10)


def test_Debugger_on_exception():
    """
    Ensure the client passes exception details to the view.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.view = mock.MagicMock()
    db.on_exception('ValueError', 'foo')
    db.view.debug_on_exception.assert_called_once_with('ValueError', 'foo')


def test_Debugger_on_postmortem():
    """
    Ensure the client passes on the unexpected death of the runner to the view.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.view = mock.MagicMock()
    db.on_postmortem()
    db.view.debug_on_postmortem.assert_called_once_with((), {})


def test_Debugger_on_info():
    """
    Handle the runner sending an informative log message to the client.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.view = mock.MagicMock()
    with mock.patch('mu.debugger.client.logger.info') as mock_logger:
        db.on_info('info')
        mock_logger.assert_called_once_with('Debug runner says: info')
        db.view.debug_on_info.assert_called_once_with('info')


def test_Debugger_on_warning():
    """
    Handle the runner sending a warning log message to the client.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.view = mock.MagicMock()
    with mock.patch('mu.debugger.client.logger.warning') as mock_logger:
        db.on_warning('info')
        mock_logger.assert_called_once_with('Debug runner says: info')
        db.view.debug_on_warning.assert_called_once_with('info')


def test_Debugger_on_error():
    """
    Handle the runner sending an error log message to the client.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.view = mock.MagicMock()
    with mock.patch('mu.debugger.client.logger.error') as mock_logger:
        db.on_error('info')
        mock_logger.assert_called_once_with('Debug runner says: info')
        db.view.debug_on_error.assert_called_once_with('info')


def test_Debugger_on_finished():
    """
    Ensure the client indicates the runner has completed to the view.
    """
    db = mu.debugger.client.Debugger('localhost', 1908)
    db.view = mock.MagicMock()
    db.on_finished()
    db.view.debug_on_finished.assert_called_once_with()
