# (c) 2024-26 Prof. Flavio ABREU ARAUJO. All rights reserved.
#
# Extended tests for the temp-logger package (no hardware required)

"""
Comprehensive tests for temp-logger — uses mocks for serial/TCP.

Usage:
    cd TMP117/Python/temp-logger
    python3 -m pytest tests/test_temp_logger_extended.py -v
"""

from __future__ import annotations

import csv
import tempfile
import time
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

# ---------------------------------------------------------------------------
# TCPSocket tests
# ---------------------------------------------------------------------------


class TestTCPSocket:
    """Test the TCP socket wrapper."""

    def test_buffer_management(self):
        from temp_logger.tools.myserial.scpi_universal import TCPSocket

        sock = TCPSocket("127.0.0.1", 5025)
        sock._buffer = b""
        sock.reset_input_buffer()
        assert sock._buffer == b""

    def test_readline_with_newline_in_buffer(self):
        from temp_logger.tools.myserial.scpi_universal import TCPSocket

        sock = TCPSocket("127.0.0.1", 5025)
        # Need a socket object for readline to attempt recv (but buffer already has data)
        mock_sock = MagicMock()
        mock_sock.recv.side_effect = Exception("should not be called")
        sock.sock = mock_sock
        sock._buffer = b"Hello, World!\nMore data"
        line = sock.readline()
        assert line == b"Hello, World!\n"
        assert sock._buffer == b"More data"

    def test_readline_empty_no_socket(self):
        from temp_logger.tools.myserial.scpi_universal import TCPSocket

        sock = TCPSocket("127.0.0.1", 5025)
        sock.sock = None
        sock._buffer = b""
        line = sock.readline()
        assert line == b""

    def test_write_string(self):
        from temp_logger.tools.myserial.scpi_universal import TCPSocket

        sock = TCPSocket("127.0.0.1", 5025)
        mock_sock = MagicMock()
        sock.sock = mock_sock
        sock.write("*IDN?\n")
        mock_sock.sendall.assert_called_once_with(b"*IDN?\n")

    def test_write_bytes(self):
        from temp_logger.tools.myserial.scpi_universal import TCPSocket

        sock = TCPSocket("127.0.0.1", 5025)
        mock_sock = MagicMock()
        sock.sock = mock_sock
        sock.write(b"*IDN?\n")
        mock_sock.sendall.assert_called_once_with(b"*IDN?\n")

    def test_close(self):
        from temp_logger.tools.myserial.scpi_universal import TCPSocket

        sock = TCPSocket("127.0.0.1", 5025)
        mock_sock = MagicMock()
        sock.sock = mock_sock
        sock.close()
        mock_sock.close.assert_called_once()
        assert sock.sock is None

    def test_close_no_socket(self):
        from temp_logger.tools.myserial.scpi_universal import TCPSocket

        sock = TCPSocket("127.0.0.1", 5025)
        sock.sock = None
        sock.close()  # Should not raise

    def test_port_name(self):
        from temp_logger.tools.myserial.scpi_universal import TCPSocket

        sock = TCPSocket("192.168.1.50", 5025)
        assert sock.port_name == "TCP:192.168.1.50:5025"

    def test_in_waiting_with_buffer(self):
        from temp_logger.tools.myserial.scpi_universal import TCPSocket

        sock = TCPSocket("127.0.0.1", 5025)
        sock.sock = None
        sock._buffer = b"some data"
        assert sock.in_waiting == 9

    def test_in_waiting_no_socket(self):
        from temp_logger.tools.myserial.scpi_universal import TCPSocket

        sock = TCPSocket("127.0.0.1", 5025)
        sock.sock = None
        sock._buffer = b""
        assert sock.in_waiting == 0

    def test_flush_noop(self):
        from temp_logger.tools.myserial.scpi_universal import TCPSocket

        sock = TCPSocket("127.0.0.1", 5025)
        sock.flush()  # Should not raise

    def test_reset_output_buffer_noop(self):
        from temp_logger.tools.myserial.scpi_universal import TCPSocket

        sock = TCPSocket("127.0.0.1", 5025)
        sock.reset_output_buffer()  # Should not raise

    def test_connect_failure(self):
        from temp_logger.tools.myserial.scpi_universal import TCPSocket

        sock = TCPSocket("192.0.2.1", 5025, timeout=0.1)  # RFC 5737 TEST-NET
        with pytest.raises(ConnectionError):
            sock.connect()


# ---------------------------------------------------------------------------
# SCPIUniversal tests
# ---------------------------------------------------------------------------


class TestSCPIUniversal:
    """Test the universal SCPI interface (mocked connections)."""

    def test_not_connected_write_raises(self):
        from temp_logger.tools.myserial.scpi_universal import SCPIUniversal

        instr = SCPIUniversal(auto_connect=False)
        with pytest.raises(ConnectionError):
            instr.write("*RST")

    def test_not_connected_query_raises(self):
        from temp_logger.tools.myserial.scpi_universal import SCPIUniversal

        instr = SCPIUniversal(auto_connect=False)
        with pytest.raises(ConnectionError):
            instr.query("*IDN?")

    def test_port_name_disconnected(self):
        from temp_logger.tools.myserial.scpi_universal import SCPIUniversal

        instr = SCPIUniversal(auto_connect=False)
        assert instr.port_name == "Not connected"

    def test_connection_type_default(self):
        from temp_logger.tools.myserial.scpi_universal import SCPIUniversal

        instr = SCPIUniversal(auto_connect=False)
        assert instr.connection_type == "disconnected"

    def test_close_disconnected(self):
        from temp_logger.tools.myserial.scpi_universal import SCPIUniversal

        instr = SCPIUniversal(auto_connect=False)
        instr.close()  # Should not raise
        assert instr.connection_type == "disconnected"

    def test_tcp_write_and_query(self):
        from temp_logger.tools.myserial.scpi_universal import SCPIUniversal, TCPSocket

        instr = SCPIUniversal(auto_connect=False)
        instr._connected = True
        instr.connection_type = "tcp"
        mock_tcp = MagicMock(spec=TCPSocket)
        mock_tcp.timeout = 2.0
        mock_tcp.sock = MagicMock()
        mock_tcp.readline.return_value = b"NEnG,TMP117,SN,0.1.0\n"
        instr._tcp = mock_tcp
        instr.sync_mode = False

        # Test query
        result = instr.query("*IDN?")
        assert "NEnG" in result

    def test_context_manager(self):
        from temp_logger.tools.myserial.scpi_universal import SCPIUniversal

        instr = SCPIUniversal(auto_connect=False)
        instr._connected = True
        instr.connection_type = "tcp"
        instr._tcp = MagicMock()
        with instr as i:
            assert i is instr
        assert instr.connection_type == "disconnected"

    def test_serial_property_tcp(self):
        from temp_logger.tools.myserial.scpi_universal import SCPIUniversal, TCPSocket

        instr = SCPIUniversal(auto_connect=False)
        mock_tcp = MagicMock(spec=TCPSocket)
        instr._tcp = mock_tcp
        instr.connection_type = "tcp"
        assert instr.serial is mock_tcp

    def test_serial_property_disconnected(self):
        from temp_logger.tools.myserial.scpi_universal import SCPIUniversal

        instr = SCPIUniversal(auto_connect=False)
        assert instr.serial is None

    def test_query_binary_not_connected(self):
        from temp_logger.tools.myserial.scpi_universal import SCPIUniversal

        instr = SCPIUniversal(auto_connect=False)
        with pytest.raises(ConnectionError):
            instr.query_binary("*IDN?")


# ---------------------------------------------------------------------------
# LoggerConfig tests
# ---------------------------------------------------------------------------


class TestLoggerConfig:
    """Test the LoggerConfig dataclass."""

    def test_defaults(self):
        from temp_logger.logger import LoggerConfig

        cfg = LoggerConfig()
        assert cfg.port is None
        assert cfg.output_file is None
        assert cfg.max_points == 101
        assert cfg.avg_level == 8

    def test_custom_values(self):
        from temp_logger.logger import LoggerConfig

        cfg = LoggerConfig(port="/dev/ttyUSB0", max_points=500, avg_level=32)
        assert cfg.port == "/dev/ttyUSB0"
        assert cfg.max_points == 500
        assert cfg.avg_level == 32


# ---------------------------------------------------------------------------
# TemperatureLogger tests (with mocked serial)
# ---------------------------------------------------------------------------


class MockSCPISerial:
    """Mocked SCPISerial that returns canned responses."""

    def __init__(self, *, sensor_count=2, port=None, **kw):
        self._sensor_count = sensor_count
        self.port_name = port or "/dev/mock"
        self._call_count = 0

    def query(self, cmd):
        cmd = cmd.strip()
        if cmd == "*IDN?":
            return "NEnG,TMP117,MOCK,0.1.0"
        if cmd == "SENS:COUNT?":
            return str(self._sensor_count)
        if cmd.startswith("SENS:SYNC?"):
            self._call_count += 1
            ts = time.monotonic()
            return f"{ts:.6f},25.123,25.456,0.150,-0.333"
        if cmd == "SENS:TEMP?":
            self._call_count += 1
            ts = time.monotonic()
            return f"{ts:.6f},25.123"
        return "0"

    def close(self):
        pass


class TestTemperatureLogger:
    """Test the TemperatureLogger with mocked serial."""

    def test_logger_creation(self):
        from temp_logger.logger import LoggerConfig, TemperatureLogger

        cfg = LoggerConfig(max_points=50)
        logger = TemperatureLogger(cfg)
        assert logger.running is False
        assert logger.sensor_count == 1

    def test_logger_default_output_file(self):
        from temp_logger.logger import LoggerConfig, TemperatureLogger

        cfg = LoggerConfig()
        logger = TemperatureLogger(cfg)
        # __init__ auto-generates a timestamped filename when none is provided
        assert logger.config.output_file is not None
        assert "tmp117_log_" in str(logger.config.output_file)

    @patch("temp_logger.logger.SCPISerial")
    def test_connect_dual_sensor(self, mock_serial_cls):
        from temp_logger.logger import LoggerConfig, TemperatureLogger

        mock_serial_cls.return_value = MockSCPISerial(sensor_count=2)

        with tempfile.NamedTemporaryFile(suffix=".csv", delete=False, mode="w") as f:
            path = Path(f.name)

        try:
            cfg = LoggerConfig(output_file=path)
            logger = TemperatureLogger(cfg)
            logger.connect()
            assert logger.is_dual_sensor is True
            assert logger.sensor_count == 2
            logger.close()

            # Check CSV header
            with open(path) as f:
                reader = csv.reader(f)
                header = next(reader)
                assert "T1_C" in header
                assert "T2_C" in header
                assert "Skew_ms" in header
        finally:
            path.unlink(missing_ok=True)

    @patch("temp_logger.logger.SCPISerial")
    def test_connect_single_sensor(self, mock_serial_cls):
        from temp_logger.logger import LoggerConfig, TemperatureLogger

        mock_serial_cls.return_value = MockSCPISerial(sensor_count=1)

        with tempfile.NamedTemporaryFile(suffix=".csv", delete=False, mode="w") as f:
            path = Path(f.name)

        try:
            cfg = LoggerConfig(output_file=path)
            logger = TemperatureLogger(cfg)
            logger.connect()
            assert logger.is_dual_sensor is False
            assert logger.sensor_count == 1
            logger.close()

            # Check CSV header
            with open(path) as f:
                reader = csv.reader(f)
                header = next(reader)
                assert "T1_C" in header
                assert "T2_C" not in header
        finally:
            path.unlink(missing_ok=True)

    @patch("temp_logger.logger.SCPISerial")
    def test_start_stop_collects_data(self, mock_serial_cls):
        from temp_logger.logger import LoggerConfig, TemperatureLogger

        mock_serial_cls.return_value = MockSCPISerial(sensor_count=2)

        with tempfile.NamedTemporaryFile(suffix=".csv", delete=False, mode="w") as f:
            path = Path(f.name)

        try:
            cfg = LoggerConfig(output_file=path, max_points=50)
            logger = TemperatureLogger(cfg)
            logger.connect()
            logger.start()
            assert logger.running is True
            time.sleep(0.15)  # Let it collect a few points
            logger.stop()
            assert logger.running is False

            with logger.data_lock:
                count = logger.point_count

            assert count > 0
            logger.close()

            # Verify CSV has data rows
            with open(path) as f:
                reader = csv.reader(f)
                rows = list(reader)
                assert len(rows) > 1  # Header + at least one data row
        finally:
            path.unlink(missing_ok=True)

    @patch("temp_logger.logger.SCPISerial")
    def test_start_idempotent(self, mock_serial_cls):
        from temp_logger.logger import LoggerConfig, TemperatureLogger

        mock_serial_cls.return_value = MockSCPISerial(sensor_count=1)

        with tempfile.NamedTemporaryFile(suffix=".csv", delete=False, mode="w") as f:
            path = Path(f.name)

        try:
            cfg = LoggerConfig(output_file=path)
            logger = TemperatureLogger(cfg)
            logger.connect()
            logger.start()
            logger.start()  # Second start should be a no-op
            assert logger.running is True
            logger.stop()
            logger.close()
        finally:
            path.unlink(missing_ok=True)

    def test_data_deques_bounded(self):
        from temp_logger.logger import LoggerConfig, TemperatureLogger

        cfg = LoggerConfig(max_points=10)
        logger = TemperatureLogger(cfg)
        assert logger.timestamps.maxlen == 10
        assert logger.temperatures_1.maxlen == 10
