import getpass
import grp
import hashlib
import logging
import os
import pwd
import secrets
import signal
import subprocess
import time
from argparse import Namespace
from datetime import timedelta
from unittest.mock import Mock, patch

import pexpect
import pytest

from zino import state, zino
from zino.scheduler import get_scheduler
from zino.state import ZinoState
from zino.time import now


def test_zino_version_should_be_available():
    from zino import version

    assert version.__version__
    assert version.__version_tuple__


def test_zino_help_screen_should_not_crash():
    assert subprocess.check_call(["zino", "--help"]) == 0


def test_zino_should_not_crash_right_away(polldevs_conf_with_no_routers, unused_udp_port, zino_conf):
    """This tests that the main function runs Zino for at least 2 seconds"""
    seconds_to_run_for = 2
    subprocess.check_call(
        [
            "zino",
            "--stop-in",
            str(seconds_to_run_for),
            "--polldevs",
            str(polldevs_conf_with_no_routers),
            "--config-file",
            str(zino_conf),
            "--trap-port",
            str(unused_udp_port),
        ]
    )


def test_zino_should_run_with_pollfile_name_in_config_file(polldevs_conf_with_no_routers, unused_udp_port, zino_conf):
    """This tests that the main function runs Zino for at least 2 seconds when
    the name of the pollfile is defined in the config file
    """
    seconds_to_run_for = 2
    subprocess.check_call(
        [
            "zino",
            "--stop-in",
            str(seconds_to_run_for),
            "--config-file",
            str(zino_conf),
            "--trap-port",
            str(unused_udp_port),
        ]
    )


def test_zino_should_not_run_without_pollfile(zino_conf_with_non_existent_pollfile, unused_udp_port):
    """This tests that the main function does not Zino for at least 2 seconds when
    the name of the pollfile is defined in the config file, but does not exist
    """
    with pytest.raises(subprocess.CalledProcessError):
        seconds_to_run_for = 2
        subprocess.check_call(
            [
                "zino",
                "--stop-in",
                str(seconds_to_run_for),
                "--config-file",
                str(zino_conf_with_non_existent_pollfile),
                "--trap-port",
                str(unused_udp_port),
            ]
        )


def test_when_args_specified_config_file_does_not_exist_then_load_config_should_exit(tmp_path):
    args = Mock(config_file=tmp_path / "non_existent_file.toml")
    with pytest.raises(SystemExit):
        zino.load_config(args)


def test_when_logging_config_is_invalid_then_apply_logging_config_should_exit():
    with pytest.raises(SystemExit):
        zino.apply_logging_config({"loggers": {"zino": {"level": "invalid"}}})


def test_zino_should_not_run_with_invalid_conf_file(invalid_zino_conf, unused_udp_port):
    """This tests that the main function does not Zino for at least 2 seconds when
    the name of the pollfile is defined in the config file, but does not exist
    """
    with pytest.raises(subprocess.CalledProcessError):
        seconds_to_run_for = 2
        subprocess.check_call(
            [
                "zino",
                "--stop-in",
                str(seconds_to_run_for),
                "--config-file",
                str(invalid_zino_conf),
                "--trap-port",
                str(unused_udp_port),
            ]
        )


def test_zino_argparser_works(polldevs_conf):
    parser = zino.parse_args(["--polldevs", str(polldevs_conf)])
    assert isinstance(parser, Namespace)


@pytest.mark.skipif(os.geteuid() == 0, reason="cannot test while being root")
def test_when_unprivileged_user_asks_for_privileged_port_zino_should_exit_with_error(polldevs_conf_with_no_routers):
    seconds_to_run_for = 5
    with pytest.raises(subprocess.CalledProcessError):
        subprocess.check_call(
            [
                "zino",
                "--stop-in",
                str(seconds_to_run_for),
                "--polldevs",
                str(polldevs_conf_with_no_routers),
                "--trap-port",
                "162",
            ]
        )


@patch("zino.zino.os.geteuid", return_value=0)
@patch("zino.zino.switch_to_user")
async def test_when_args_specify_user_zino_init_event_loop_should_attempt_to_switch_users(
    switch_to_user, mock_geteuid, event_loop
):
    """Detect attempt to user switching by patching in a mock exception.  This is to avoid setting up the full Zino
    daemon and mucking up the event loop and state, by ensuring we exit as soon as switch_to_user is called.
    """

    class MockError(Exception):
        pass

    zino.switch_to_user.side_effect = MockError()
    with pytest.raises(MockError):
        try:
            zino.init_event_loop(args=Mock(trap_port=0, user="nobody"), loop=event_loop)
        except MockError:
            raise
        except Exception:
            pass


@patch("zino.zino.os.geteuid", return_value=0)
async def test_when_running_as_root_without_user_config_should_log_warning(mock_geteuid, event_loop, caplog):
    """When running as root without a user configured for privilege dropping, a warning should be logged."""
    import logging

    with caplog.at_level(logging.WARNING):
        try:
            zino.init_event_loop(args=Mock(trap_port=0, user=None, stop_in=None), loop=event_loop)
        except Exception:
            pass

    assert "Zino is running with root privileges" in caplog.text


class TestZinoRescheduleDumpStateOnCommit:
    def test_when_more_than_10_seconds_remains_until_next_dump_it_should_reschedule(self):
        scheduler = get_scheduler()
        mock_job = Mock(next_run_time=now() + timedelta(minutes=5))
        mock_event = Mock(id=42)

        with patch.object(scheduler, "get_job") as get_job:
            get_job.return_value = mock_job
            zino.reschedule_dump_state_on_commit(mock_event)
            assert mock_job.modify.called

    def test_when_less_than_10_seconds_remains_until_next_dump_it_should_not_reschedule(self):
        scheduler = get_scheduler()
        mock_job = Mock(next_run_time=now() + timedelta(seconds=5))
        mock_event = Mock(id=42)

        with patch.object(scheduler, "get_job") as get_job:
            get_job.return_value = mock_job
            zino.reschedule_dump_state_on_commit(mock_event)
            assert not mock_job.modify.called


class TestZinoRescheduleDumpStateOnPmChange:
    def test_when_more_than_10_seconds_remain_until_next_dump_it_should_reschedule(self):
        scheduler = get_scheduler()
        mock_job = Mock(next_run_time=now() + timedelta(minutes=5))

        with patch.object(scheduler, "get_job") as get_job:
            get_job.return_value = mock_job
            zino.reschedule_dump_state_on_pm_change()
            assert mock_job.modify.called

    def test_when_less_than_10_seconds_remain_until_next_dump_it_should_not_reschedule(self):
        scheduler = get_scheduler()
        mock_job = Mock(next_run_time=now() + timedelta(seconds=5))

        with patch.object(scheduler, "get_job") as get_job:
            get_job.return_value = mock_job
            zino.reschedule_dump_state_on_pm_change()
            assert not mock_job.modify.called


class TestSwitchUser:
    def test_when_switching_to_current_user_it_should_succeed(self):
        username = getpass.getuser()
        assert zino.switch_to_user(username)

    def test_when_user_does_not_exist_it_should_return_false(self):
        random_username = secrets.token_hex(5)
        print(f"attempting switch to user {random_username!r}")
        assert not zino.switch_to_user(random_username)

    @patch("pwd.getpwnam")
    @patch("grp.getgrall")
    @patch("os.setgid")
    @patch("os.setgroups")
    @patch("os.setuid")
    def test_when_user_exists_it_should_switch_correctly_to_uid_gid_and_groups(
        self, getpwnam, getgrall, setgid, setgroups, setuid
    ):
        random_username = secrets.token_hex(5)
        pwd.getpwnam.return_value = Mock(pw_uid=666, pw_gid=999)
        grp.getgrall.return_value = [Mock(gr_gid=4242, gr_mem=[random_username])]

        assert zino.switch_to_user(random_username)
        os.setgid.assert_called_once_with(999)
        os.setuid.assert_called_once_with(666)
        os.setgroups.assert_called_once_with([4242])

    @patch("pwd.getpwnam")
    @patch("grp.getgrall")
    @patch("os.setgid")
    @patch("os.setgroups")
    @patch("os.setuid")
    def test_when_setgid_raises_oserror_it_should_return_false(self, getpwnam, getgrall, setgid, setgroups, setuid):
        random_username = secrets.token_hex(5)
        pwd.getpwnam.return_value = Mock(pw_uid=666, pw_gid=999)
        grp.getgrall.return_value = [Mock(gr_gid=4242, gr_mem=[random_username])]
        os.setgid.side_effect = OSError("Mock error")

        assert not zino.switch_to_user(random_username)


class TestCountReachableObjects:
    def test_it_should_count_queried_classes(self):
        counts = zino._count_reachable_objects(str, int)
        assert int in counts
        assert counts[int] > 0
        assert str in counts
        assert counts[str] > 0

    def test_it_should_not_count_non_queried_classes(self):
        counts = zino._count_reachable_objects(str)
        assert int not in counts


class TestLogSnmpSessionState:
    async def test_when_netsnmpy_is_backend_it_should_log_low_level_details(self, state_with_localhost, caplog):
        import zino.snmp.netsnmpy_backend as backend

        with patch.object(zino.state, attribute="state", new=state_with_localhost):
            with patch.object(zino, attribute="import_snmp_backend") as import_snmp_backend:
                import_snmp_backend.return_value = backend
                with caplog.at_level(logging.DEBUG):
                    await zino.log_snmp_session_stats()
                    assert "gc reachable (low-level)=" in caplog.text

    async def test_when_pysnmp_is_backend_it_should_not_log_low_level_details(self, state_with_localhost, caplog):
        import zino.snmp.pysnmp_backend as backend

        with patch.object(zino.state, attribute="state", new=state_with_localhost):
            with patch.object(zino, attribute="import_snmp_backend") as import_snmp_backend:
                import_snmp_backend.return_value = backend
                with caplog.at_level(logging.DEBUG):
                    await zino.log_snmp_session_stats()
                    assert "gc reachable (low-level)=" not in caplog.text

    async def test_when_debug_logging_is_not_enabled_it_should_not_log_anything(self, state_with_localhost, caplog):
        import zino.snmp.netsnmpy_backend as backend

        with patch.object(zino.state, attribute="state", new=state_with_localhost):
            with patch.object(zino, attribute="import_snmp_backend") as import_snmp_backend:
                import_snmp_backend.return_value = backend
                with caplog.at_level(logging.INFO):
                    await zino.log_snmp_session_stats()
                    assert "gc reachable" not in caplog.text


class TestSecretsFileConfiguration:
    def test_users_from_secrets_file_should_be_able_to_authenticate(
        self, polldevs_conf_with_no_routers, zino_conf, secrets_file
    ):
        """Test connecting to the server, handling SHA1 challenge, and asserting successful login."""
        # Start the process
        zino_process = subprocess.Popen(["zino", "--config-file", str(zino_conf), "--trap-port", "0"])
        with open(secrets_file, "r") as f:
            username, password = f.readline().strip().split(" ", 1)
        time.sleep(1)  # Wait for the server to start
        try:
            client = pexpect.spawn("telnet localhost 8001", timeout=5)
            client.expect("200 ([a-f0-9]+) Hello, there")
            challenge = client.match.group(1).decode("utf-8")

            response = hashlib.sha1((challenge + " " + password).encode("utf-8")).hexdigest()

            client.sendline(f"USER {username} {response}")
            client.expect(["200 ok", "500 Authentication failure"])
            assert "200 ok" in client.after.decode("utf-8")

        finally:
            zino_process.terminate()


class TestDumpStateWithFork:
    async def test_should_produce_valid_state_file(self, tmp_path):
        """fork_and_dump_state should fork a child that writes a valid JSON state file."""
        filename = str(tmp_path / "state.json")
        with patch.object(state, "state", new=ZinoState()):
            zino._dump_child_pid = 0
            await zino.fork_and_dump_state(filename)
            # Wait for the child to finish
            os.waitpid(zino._dump_child_pid, 0)
            zino._dump_child_pid = 0

        assert os.path.exists(filename)
        loaded = ZinoState.load_state_from_file(filename)
        assert loaded is not None

    async def test_should_skip_when_previous_child_still_running(self, tmp_path, caplog):
        """A second call while a child is still running should log a warning and skip."""
        filename = str(tmp_path / "state.json")
        with patch.object(state, "state", new=ZinoState()):
            zino._dump_child_pid = 0
            # Fork a child that sleeps so it's still running when we call again
            pid = os.fork()
            if pid == 0:
                time.sleep(10)
                os._exit(0)
            try:
                zino._dump_child_pid = pid
                with caplog.at_level(logging.WARNING):
                    await zino.fork_and_dump_state(filename)
                assert "still running, skipping" in caplog.text
                assert not os.path.exists(filename)
            finally:
                os.kill(pid, signal.SIGKILL)
                os.waitpid(pid, 0)
                zino._dump_child_pid = 0

    def test_reap_dump_child_should_reap_finished_child(self):
        """_reap_dump_child should reap a finished child and reset the PID."""
        pid = os.fork()
        if pid == 0:
            os._exit(0)
        # Wait briefly for the child to actually exit
        time.sleep(0.1)
        zino._dump_child_pid = pid
        zino._reap_dump_child()
        assert zino._dump_child_pid == 0

    def test_reap_dump_child_should_log_error_on_nonzero_exit(self, caplog):
        """_reap_dump_child should log an error when the child exits with a non-zero status."""
        pid = os.fork()
        if pid == 0:
            os._exit(1)
        time.sleep(0.1)
        zino._dump_child_pid = pid
        with caplog.at_level(logging.ERROR):
            zino._reap_dump_child()
        assert zino._dump_child_pid == 0
        assert "exited with status 1" in caplog.text

    def test_reap_dump_child_should_noop_when_no_child(self):
        """_reap_dump_child should be a no-op when there is no child PID tracked."""
        zino._dump_child_pid = 0
        zino._reap_dump_child()
        assert zino._dump_child_pid == 0

    def test_reap_dump_child_should_log_error_when_child_killed_by_signal(self, caplog):
        fake_pid = 99999
        zino._dump_child_pid = fake_pid
        signal_status = signal.SIGKILL  # On Linux, WIFSIGNALED status for SIGKILL
        with patch("os.waitpid", return_value=(fake_pid, signal_status)):
            with caplog.at_level(logging.ERROR):
                zino._reap_dump_child()
        assert zino._dump_child_pid == 0
        assert f"killed by signal {signal.SIGKILL}" in caplog.text

    def test_reap_dump_child_should_handle_child_process_error(self):
        """_reap_dump_child should handle ChildProcessError gracefully."""
        zino._dump_child_pid = 99999999
        with patch("os.waitpid", side_effect=ChildProcessError):
            zino._reap_dump_child()
        assert zino._dump_child_pid == 0


class TestWaitForDumpChild:
    def test_when_no_child_it_should_noop(self):
        zino._dump_child_pid = 0
        zino._wait_for_dump_child()
        assert zino._dump_child_pid == 0

    def test_it_should_wait_for_running_child(self):
        pid = os.fork()
        if pid == 0:
            time.sleep(0.2)
            os._exit(0)
        zino._dump_child_pid = pid
        zino._wait_for_dump_child()
        assert zino._dump_child_pid == 0

    def test_it_should_handle_child_process_error(self):
        zino._dump_child_pid = 99999999
        with patch("os.waitpid", side_effect=ChildProcessError):
            zino._wait_for_dump_child()
        assert zino._dump_child_pid == 0


class TestFinalStateDumpOnShutdown:
    @pytest.fixture()
    def _skip_init_side_effects(self):
        """Patches out setup_initial_job_schedule and ZinoServer so that
        init_event_loop only exercises the startup/shutdown path."""
        with (
            patch.object(zino, "setup_initial_job_schedule"),
            patch.object(zino, "ZinoServer"),
        ):
            yield

    @pytest.mark.usefixtures("_skip_init_side_effects")
    def test_it_should_dump_state_on_shutdown(self, tmp_path):
        state_file = str(tmp_path / "state.json")
        mock_config = Mock()
        mock_config.persistence.file = state_file
        mock_config.process.user = None
        mock_config.snmp.agent.enabled = False

        loop = Mock()
        loop.run_forever.side_effect = KeyboardInterrupt
        mock_state = ZinoState()

        with (
            patch.object(state, "state", new=mock_state),
            patch.object(state, "config", new=mock_config),
            patch.object(zino, "_dump_child_pid", 0),
        ):
            zino.init_event_loop(args=Mock(trap_port=0, user=None, stop_in=None), loop=loop)

        assert os.path.exists(state_file)
        loaded = ZinoState.load_state_from_file(state_file)
        assert loaded is not None

    @pytest.mark.usefixtures("_skip_init_side_effects")
    def test_when_dump_fails_it_should_log_exception(self, tmp_path, caplog):
        """init_event_loop should log but not raise if the final dump fails."""
        mock_config = Mock()
        mock_config.persistence.file = "/nonexistent/path/state.json"
        mock_config.process.user = None
        mock_config.snmp.agent.enabled = False

        loop = Mock()
        loop.run_forever.side_effect = KeyboardInterrupt
        mock_state = Mock()
        mock_state.dump_state_to_file.side_effect = OSError("disk full")

        with (
            patch.object(state, "state", new=mock_state),
            patch.object(state, "config", new=mock_config),
            patch.object(zino, "_dump_child_pid", 0),
            caplog.at_level(logging.ERROR),
        ):
            zino.init_event_loop(args=Mock(trap_port=0, user=None, stop_in=None), loop=loop)

        assert "Final state dump failed" in caplog.text

    @pytest.mark.usefixtures("_skip_init_side_effects")
    def test_it_should_wait_for_dump_child_before_final_dump(self, tmp_path):
        state_file = str(tmp_path / "state.json")
        mock_config = Mock()
        mock_config.persistence.file = state_file
        mock_config.process.user = None
        mock_config.snmp.agent.enabled = False

        loop = Mock()
        loop.run_forever.side_effect = KeyboardInterrupt
        mock_state = ZinoState()

        with (
            patch.object(state, "state", new=mock_state),
            patch.object(state, "config", new=mock_config),
            patch.object(zino, "_wait_for_dump_child") as mock_wait,
        ):
            zino.init_event_loop(args=Mock(trap_port=0, user=None, stop_in=None), loop=loop)

        mock_wait.assert_called_once()
