#!/usr/bin/env python3

"""Tests for zdbg module."""

from io import StringIO
import contextlib
import os
import sys
import timeit
from datetime import datetime
import inspect
import atexit
import tempfile
import zdbg

# Lines marked with <-- are referenced in messages (for context).
# If we are willing to update to a minimum version of 3.8, we can use
# __line__ to get the line number, instead.

THIS_FILE = "test_zdbg.py"


class TTY(StringIO):
    """A StringIO that pretends to be a TTY."""
    def isatty(self) -> bool:
        return True


_ERROR_PREFIX = "Error: "
_DEBUG_PREFIX = "DEBUG: "
_COLOR_ERROR_PREFIX = "\x1b[1m\x1b[31mError\x1b[0m\x1b[1m: "
_COLOR_DEBUG_PREFIX = "\x1b[32mDEBUG\x1b[0m: "


def _get_line() -> int:
    """Get the current line number in the caller's frame."""
    return inspect.currentframe().f_back.f_lineno # type: ignore[union-attr]


def _check_timestamp(prefix: str, start: datetime, output: str) -> None:
    """Test timestamp in debug output."""
    assert output.strip(), "No output produced"

    # Expected format: "DEBUG: 2025-11-19 13:45:21 hello"
    # Extract the timestamp part (between "DEBUG: " and the next space)
    _, _, rest = output.partition(prefix)
    timestamp_str = rest[:19]   # "YYYY-MM-DD HH:MM:SS"
    t_msg = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
    delta = abs((t_msg - start).total_seconds())
    assert delta <= 2.0, f"Timestamp mismatch: {t_msg} vs acquisition time {start}"


#================================================================================
# Tests without color support
#================================================================================


def test_with_context() -> None:
    """Test with context."""

    # Keep this high in the file so the line number is predictable.
    buf = StringIO()
    zdbg.configure(["debug", "context", "noflush_debug", "noflush_error"],
                   debugout=buf, errorout=buf)
    zdbg.debug("testing context") ; line = _get_line()  # <-- pylint: disable=multiple-statements
    output = buf.getvalue()
    assert "testing context" in output
    assert f"{THIS_FILE}:{line}" in output

    # Reset the buffer.
    buf.truncate(0)
    buf.seek(0)
    zdbg.error("testing error with context") ; line = _get_line()  # <-- pylint: disable=multiple-statements
    err_output: str = buf.getvalue()
    assert "testing error with context" in err_output
    assert f"{THIS_FILE}:{line}" in err_output


def test_timestamp_in_debug_output() -> None:
    """Test timestamp in debug output."""
    buf = StringIO()
    zdbg.configure(["debug", "timestamp"], debugout=buf)
    start = datetime.now()
    zdbg.debug("hello")
    output = buf.getvalue()
    _check_timestamp(_DEBUG_PREFIX, start, output)


def test_timestamp_in_error_output() -> None:
    """Test timestamp in error output."""
    buf = StringIO()
    zdbg.configure(["timestamp"], errorout=buf)
    start = datetime.now()
    zdbg.error("check")
    output = buf.getvalue()
    _check_timestamp(_ERROR_PREFIX, start, output)


def test_timestamp_context_in_debug_output() -> None:
    """Test timestamp in debug output."""
    buf = StringIO()
    zdbg.configure(["debug", "timestamp", "context"], debugout=buf)
    start = datetime.now()
    zdbg.debug("hello")
    output = buf.getvalue()
    _check_timestamp(_DEBUG_PREFIX, start, output)


def test_timestamp_context_in_error_output() -> None:
    """Test timestamp in error output."""
    buf = StringIO()
    zdbg.configure(["timestamp", "context"], errorout=buf)
    start = datetime.now()
    zdbg.error("check")
    output = buf.getvalue()
    _check_timestamp(_ERROR_PREFIX, start, output)


def test_debug_disabled_is_noop() -> None:
    """Test debug when not enabled is a no-op."""
    buf = StringIO()
    zdbg.configure([], debugout=buf, errorout=buf)
    zdbg.debug("hello")
    assert buf.getvalue() == ""
    buf.truncate(0)
    buf.seek(0)
    zdbg.error("error message")
    assert "error message" in buf.getvalue()


def test_debug_enabled_writes() -> None:
    """Test debug when enabled writes to output."""
    buf = StringIO()
    zdbg.configure(["debug"], debugout=buf, errorout=buf)
    zdbg.debug("hello")
    assert "hello" in buf.getvalue()
    buf.truncate(0)
    buf.seek(0)
    zdbg.error("error message")
    assert "error message" in buf.getvalue()


def test_debug_lazy() -> None:
    """Test lazy debugging."""
    flag = False
    def tick() -> str:
        nonlocal flag
        flag = True
        return "!"
    buf = StringIO()
    zdbg.configure([], debugout=buf, errorout=buf)
    zdbg.debug_lazy(lambda : f"world{tick()}")
    assert buf.getvalue() == ""
    assert flag is False
    buf.truncate(0)
    buf.seek(0)
    zdbg.configure(["debug"], debugout=buf, errorout=buf)
    zdbg.debug_lazy(lambda : f"hello{tick()}")
    assert "hello" in buf.getvalue()
    assert flag is True


def test_environment_variable() -> None:
    """Test debug enabled via environment variable."""
    buf = StringIO()
    os.environ["ZDBG_DEBUG"] = "1"
    os.environ["DEBUG"] = ""
    # Using the default gets DEBUG, which is empty.
    zdbg.configure([], debugout=buf)
    zdbg.debug("world")
    assert buf.getvalue() == ""
    buf.truncate(0)
    buf.seek(0)
    # Configuring from ZDBG_DEBUG enables debugging.
    zdbg.configure(None, envvar="ZDBG_DEBUG", debugout=buf)
    zdbg.debug("world")
    assert "world" in buf.getvalue()
    del os.environ["ZDBG_DEBUG"]
    del os.environ["DEBUG"]


#================================================================================
# Tests with color support
#================================================================================


def test_color_with_context() -> None:
    """Test with context."""

    # Keep this high in the file so the line number is predictable.
    buf = TTY()
    zdbg.configure(["debug", "context", "noflush_error", "noflush_debug"],
                   debugout=buf, errorout=buf)
    zdbg.debug("testing context") ; line = _get_line()  # <-- pylint: disable=multiple-statements
    output = buf.getvalue()
    assert "testing context" in output
    assert f"{THIS_FILE}:{line}" in output

    # Reset the buffer.
    buf.truncate(0)
    buf.seek(0)
    zdbg.error("testing error with context") ; line = _get_line()  # <-- pylint: disable=multiple-statements
    err_output: str = buf.getvalue()
    assert "testing error with context" in err_output
    assert f"{THIS_FILE}:{line}" in err_output


def test_color_timestamp_in_debug_output() -> None:
    """Test timestamp in debug output."""
    buf = TTY()
    zdbg.configure(["debug", "timestamp"], debugout=buf)
    start = datetime.now()
    zdbg.debug("hello")
    output = buf.getvalue()
    _check_timestamp(_COLOR_DEBUG_PREFIX, start, output)


def test_color_timestamp_in_error_output() -> None:
    """Test timestamp in error output."""
    buf = TTY()
    zdbg.configure(["timestamp"], errorout=buf)
    start = datetime.now()
    zdbg.error("check")
    output = buf.getvalue()
    _check_timestamp(_COLOR_ERROR_PREFIX, start, output)


def test_color_timestamp_context_in_debug_output() -> None:
    """Test timestamp in debug output."""
    buf = TTY()
    zdbg.configure(["debug", "timestamp", "context"], debugout=buf)
    start = datetime.now()
    zdbg.debug("hello")
    output = buf.getvalue()
    _check_timestamp(_COLOR_DEBUG_PREFIX, start, output)


def test_color_timestamp_context_in_error_output() -> None:
    """Test timestamp in error output."""
    buf = TTY()
    zdbg.configure(["timestamp", "context"], errorout=buf)
    start = datetime.now()
    zdbg.error("check")
    output = buf.getvalue()
    _check_timestamp(_COLOR_ERROR_PREFIX, start, output)


def test_color_debug_disabled_is_noop() -> None:
    """Test debug when not enabled is a no-op."""
    buf = TTY()
    zdbg.configure([], debugout=buf, errorout=buf)
    zdbg.debug("hello")
    assert buf.getvalue() == ""
    buf.truncate(0)
    buf.seek(0)
    zdbg.error("error message")
    assert "error message" in buf.getvalue()


def test_color_debug_enabled_writes() -> None:
    """Test debug when enabled writes to output."""
    buf = TTY()
    zdbg.configure(["debug"], debugout=buf, errorout=buf)
    zdbg.debug("hello")
    assert "hello" in buf.getvalue()
    buf.truncate(0)
    buf.seek(0)
    zdbg.error("error message")
    assert "error message" in buf.getvalue()


def test_color_environment_variable() -> None:
    """Test debug enabled via environment variable."""
    buf = TTY()
    os.environ["ZDBG_DEBUG"] = "1"
    os.environ["DEBUG"] = ""
    # Using the default gets DEBUG, which is empty.
    zdbg.configure([], debugout=buf)
    zdbg.debug("world")
    assert buf.getvalue() == ""
    buf.truncate(0)
    buf.seek(0)
    # Configuring from ZDBG_DEBUG enables debugging.
    zdbg.configure(None, envvar="ZDBG_DEBUG", debugout=buf)
    zdbg.debug("world")
    assert "world" in buf.getvalue()
    del os.environ["ZDBG_DEBUG"]
    del os.environ["DEBUG"]


def test_debug_to_file() -> None:
    """Test debug output to a file."""
    with tempfile.TemporaryDirectory() as tmpdir:
        path = os.path.join(tmpdir, "debug.log")
        with open(path, "w+", encoding="utf-8") as tmpfile:
            zdbg.configure(["debug", "debugout=" + path])
            zdbg.debug("file output test")
            tmpfile.flush()
            tmpfile.seek(0)
            content = tmpfile.read()
        assert "file output test" in content


def test_check_report() -> None:
    """Check should report configuration."""
    os.environ["TEMP_DEBUG"] = "debug,check"
    buf = StringIO()
    with contextlib.redirect_stdout(buf):
        try:
            zdbg.configure(None, debugout=buf, errorout=buf, envvar="TEMP_DEBUG")
        except SystemExit:
            pass
    output = buf.getvalue()
    lines = [
        "zdbg configuration check:",
        "Configured from environment variable 'TEMP_DEBUG'",
        "DEBUG = {",
        "- debugging .............. selected",
        "- color debug messages ... disabled",
        "- color error messages ... disabled",
        "- include context ........ disabled",
        "- include timestamp ...... disabled",
        "- flush debug stream ..... selected",
        "- flush error stream ..... selected",
        "- debug written to ....... ",
        "- error written to ....... ",
        "Example debug/error output, given configuration:",
    ]
    for line in lines:
        print(line)
        assert line in output


#================================================================================
# Other tests
#================================================================================


def test_check() -> None:
    """Check should not terminate the program."""
    def fail_check() -> None:
        raise AssertionError("Unexpected exit from a check") # pragma: no cover - should not happen
    buf = StringIO()
    zdbg.configure(["debug", "check"], debugout=buf)
    atexit.register(fail_check)
    zdbg.debug("This is a check message.")
    atexit.unregister(fail_check)


#================================================================================
# End of tests
#================================================================================


def runall() -> None: # pragma: no cover - trivial
    """Find and run all tests."""
    for script in sys.modules[__name__].__dict__.values():
        if (callable(script)
            and script.__module__ == __name__
            and script.__name__.startswith("test_")):
            # Run the script, check the result, time it, and compute the overhead.
            start = timeit.default_timer()
            script()
            duration = timeit.default_timer() - start
            print(f"Test {script.__name__} passed in {duration:.6f} seconds.")
    print("All tests passed.")


if __name__ == "__main__": # pragma: no cover - trivial
    runall()
