#!/usr/bin/env python3

# zdbg
# Copyright (c) Stacy Prowell (sprowell@gmail.com)
# All rights reserved.
# This software is licensed under the MIT License. See LICENSE for details.

"""
A lightweight (zero cost) debugging helper.

Implement the core library for zdbg. See __init__.py for documentation.
"""

import sys
import time
from os import environ
from typing import Any, Iterable, TextIO, Callable, Set
from threading import Lock

# Initialize the DEBUG set. We will configure it later.
DEBUG: Set[str] = set()


_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 _is_tty(stream: TextIO) -> bool:
    isatty = getattr(stream, "isatty", None)
    try:
        return bool(isatty()) if callable(isatty) else False
    except Exception: # pylint: disable=broad-except # pragma: no cover - trivial
        return False

def _get_timestamp() -> str:
    '''Get the current timestamp as a string.'''
    return time.strftime('%Y-%m-%d %H:%M:%S ', time.localtime())

def _get_context() -> str:
    '''Get the current file:line context for debug messages.'''
    # Local import avoids global dependency and keeps import cost on the hot
    # path only when needed.
    from inspect import currentframe, getframeinfo # pylint: disable=import-outside-toplevel
    frame = currentframe()
    if frame:
        frame = frame.f_back  # our caller (debug or error implementation)
        if frame:
            frame = frame.f_back  # implementation's caller (debug or error wrapper)
            if frame:
                frame = frame.f_back  # wrapper's caller (real source)
                if frame:
                    info = getframeinfo(frame)
                    path = info.filename
                    # Keep basename and last 1 directory (heuristic)
                    import os # pylint: disable=import-outside-toplevel
                    base = os.path.basename(path)
                    parent = os.path.basename(os.path.dirname(path))
                    display = f"{parent}/{base}" if parent else base
                    return f"{display}:{info.lineno}: "
    return "" # pragma: no cover - trivial

# Initialize the error and debug functions to no-ops until configured.
def _noop(_msg: Any) -> None:  # pragma: no cover - trivial
    return
_debug_impl: Callable[[Any], None] = _noop
_error_impl: Callable[[Any], None] = _noop

# A module-level lock used during a critical section in configure().
_configure_lock = Lock()

def configure(options: Iterable[str] | None,
              debugout: TextIO = sys.stdout,
              errorout: TextIO = sys.stderr,
              envvar: str = "DEBUG") -> None:
    '''Reconfigure debugging at runtime. Pass `None` to re-parse from the environment.
    
    Optionally pass the I/O streams to use for debug (`debugout`) and error (`errorout`).
    If not specified, standard output is used for debugging messages, and standard
    error is used for error messages.

    If you pass `None` for options, the options are re-parsed from the environment
    variable named by `envvar`. By default, this is "DEBUG", but you can change it
    to use a different environment variable.

    Usage examples:

        import zdbg
        zdbg.configure(["debug", "context"])
        zdbg.debug("This will be printed with context")
    
        import zdbg
        zdbg.configure(None, envvar="ZDBG_DEBUG")
        zdbg.debug("This will be printed if ZDBG_DEBUG enables debug")
    
    '''

    # Get the list of options, normalize them, and discard any empty strings.
    if options is None:
        options = environ.get(envvar, "").split(",")
    options = [opt.strip().lower() for opt in options]
    options = [opt for opt in options if opt]

    # Handle the synonym for debug.
    if "1" in options:
        options.remove("1")
        options.append("debug")

    # Set the global DEBUG options.
    with _configure_lock:
        DEBUG.clear()
        DEBUG.update(options)

        # Get enabled options.
        include_context = "context" in options
        use_color = "nocolor" not in options
        include_timestamp = "timestamp" in options

        if "noflush_debug" in options:
            def dbg(msg: str) -> None:
                debugout.write(msg)
        else:
            def dbg(msg: str) -> None:
                debugout.write(msg)
                debugout.flush()
        if "noflush_error" in options:
            def err(msg: str) -> None:
                errorout.write(msg)
        else:
            def err(msg: str) -> None:
                errorout.write(msg)
                errorout.flush()

        # If not writing to a TTY, then we disable color.
        color_debug = use_color and _is_tty(debugout)
        color_error = use_color and _is_tty(errorout)

        # Define the debug function.
        if "debug" in options:
            __debug = _get_debug_function(color_debug,
                                         include_context,
                                         include_timestamp,
                                         dbg)
        else:
            __debug = _noop

        # Define the error function.
        __error = _get_error_function(color_error,
                                     include_context,
                                     include_timestamp,
                                     err)

        # Bind the functions.
        global _debug_impl, _error_impl # pylint: disable=global-statement, disable=invalid-name
        _debug_impl = __debug
        _error_impl = __error

# This is verbose, but it minimizes runtime cost. We still use f-strings since
# formatting the string in memory is likely faster than multiple writes to the
# output stream. Still, we explicitly instantiate any constant strings.
#
# We break these out to avoid an over-long function.

def _get_debug_function(color_debug: bool,
                        include_context: bool,
                        include_timestamp: bool,
                        dbg: Callable[[Any], None]) -> Callable[[Any], None]:
    """Determine the implementation of debug to use."""
    # Define the debug function.
    if color_debug:
        # Four cases. Context and timestamp, context only, timestamp only, neither.
        if include_context and include_timestamp:
            def __debug(_msg: Any) -> None:
                timestamp = _get_timestamp()
                con = _get_context()
                dbg(f"{_COLOR_DEBUG_PREFIX}{timestamp}{con}{_msg}\n")
        elif include_context:
            def __debug(_msg: Any) -> None:
                con = _get_context()
                dbg(f"{_COLOR_DEBUG_PREFIX}{con}{_msg}\n")
        elif include_timestamp:
            def __debug(_msg: Any) -> None:
                timestamp = _get_timestamp()
                dbg(f"{_COLOR_DEBUG_PREFIX}{timestamp}{_msg}\n")
        else:
            def __debug(_msg: Any) -> None:
                dbg(f"{_COLOR_DEBUG_PREFIX}{_msg}\n")
    else:
        # Four cases. Context and timestamp, context only, timestamp only, neither.
        if include_context and include_timestamp:
            def __debug(_msg: Any) -> None:
                timestamp = _get_timestamp()
                con = _get_context()
                dbg(f"{_DEBUG_PREFIX}{timestamp}{con}{_msg}\n")
        elif include_context:
            def __debug(_msg: Any) -> None:
                con = _get_context()
                dbg(f"{_DEBUG_PREFIX}{con}{_msg}\n")
        elif include_timestamp:
            def __debug(_msg: Any) -> None:
                timestamp = _get_timestamp()
                dbg(f"{_DEBUG_PREFIX}{timestamp}{_msg}\n")
        else:
            def __debug(_msg: Any) -> None:
                dbg(f"{_DEBUG_PREFIX}{_msg}\n")
    return __debug

def _get_error_function(color_error: bool,
                        include_context: bool,
                        include_timestamp: bool,
                        err: Callable[[Any], None]) -> Callable[[Any], None]:
    """Determine the implementation of error to use."""
    # Define the error function.
    if color_error:
        # Four cases. Context and timestamp, context only, timestamp only, neither.
        if include_context and include_timestamp:
            def __error(_msg: Any) -> None:
                timestamp = _get_timestamp()
                con = _get_context()
                err(f"{_COLOR_ERROR_PREFIX}{timestamp}{con}{_msg}\x1b[0m\n")
        elif include_context:
            def __error(_msg: Any) -> None:
                con = _get_context()
                err(f"{_COLOR_ERROR_PREFIX}{con}{_msg}\x1b[0m\n")
        elif include_timestamp:
            def __error(_msg: Any) -> None:
                timestamp = _get_timestamp()
                err(f"{_COLOR_ERROR_PREFIX}{timestamp}{_msg}\x1b[0m\n")
        else:
            def __error(_msg: Any) -> None:
                err(f"{_COLOR_ERROR_PREFIX}{_msg}\x1b[0m\n")
    else:
        # Four cases. Context and timestamp, context only, timestamp only, neither.
        if include_context and include_timestamp:
            def __error(_msg: Any) -> None:
                timestamp = _get_timestamp()
                con = _get_context()
                err(f"{_ERROR_PREFIX}{timestamp}{con}{_msg}\n")
        elif include_context:
            def __error(_msg: Any) -> None:
                con = _get_context()
                err(f"{_ERROR_PREFIX}{con}{_msg}\n")
        elif include_timestamp:
            def __error(_msg: Any) -> None:
                timestamp = _get_timestamp()
                err(f"{_ERROR_PREFIX}{timestamp}{_msg}\n")
        else:
            def __error(_msg: Any) -> None:
                err(f"{_ERROR_PREFIX}{_msg}\n")
    return __error

def debug(msg: Any) -> None:
    """Write a debug message, depending on configuration."""
    _debug_impl(msg)

def error(msg: Any) -> None:
    """Write an error message."""
    _error_impl(msg)

# Configure now using defaults.
configure(None)
