#!/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 (nearly 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, cast
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}{os.sep}{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_root: 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 | None = None,
              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.

    NB: If both an output stream and a `debugout=path` option are specified, the output
    stream takes precedence.

    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.
    source = None
    if options is None:
        options = environ.get(envvar, "").split(",")
        source = "environment variable " + repr(envvar)
    options = [opt.strip() for opt in options]
    options = [opt for opt in options if opt]
    if source is None and "check" in options:
        options.remove("check")

    # Determine where to send debug output.
    if debugout is None:
        for opt in options:
            if opt.startswith("debugout="):
                path = opt[9:]
                if path:
                    debugout = open(path, "a", encoding="utf-8") # pylint: disable=consider-using-with
                break
    if debugout is None:
        debugout = sys.stdout
    debugout = cast(TextIO, debugout)  # for mypy

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

    # Set up debugging.
    with _configure_lock:
        _configure_debug(options, debugout, errorout, source)

def _configure_debug(options: Iterable[str],
                     debugout: TextIO,
                     errorout: TextIO,
                     source: str | None,
                     suppress_error: bool = False) -> None:
    """Internal function to configure debugging."""
    # This function exists to break up a long function that pylint complains about.
    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_root, _error_impl # pylint: disable=global-statement, disable=invalid-name
    debug_root = __debug
    _error_impl = __error

    if "check" in options:
        print("zdbg configuration check:")
        print(f"Configured from {source if source else 'configure() call'}")
        print(f"DEBUG = {repr(DEBUG)}")
        print(_status("debugging .............. ", "debug"))
        print(_status("color debug messages ... ", None, color_debug))
        print(_status("color error messages ... ", None, color_error))
        print(_status("include context ........ ", None, include_context))
        print(_status("include timestamp ...... ", None, include_timestamp))
        print(_status("flush debug stream ..... ", "noflush_debug", invert=True))
        print(_status("flush error stream ..... ", "noflush_error", invert=True))
        print(     f"- debug written to ....... {getattr(debugout, 'name', repr(debugout))}")
        print(     f"- error written to ....... {getattr(errorout, 'name', repr(errorout))}")
        print("Example debug/error output, given configuration:")
        debug_root("This is a debug message")
        _error_impl("This is an error message")
        if not suppress_error:
            sys.exit(0)

# If check is specified, then print out how the options are interpreted,
# and exit.
def _status(thing: str, option: str | None,
            value: bool = False, invert: bool = False) -> str:
    """Return the message needed for checking the configuration."""
    affirm = 'selected' if not invert else 'disabled'
    deny = 'disabled' if not invert else 'selected'
    if option is None:
        return f"- {thing}{affirm if value else deny}"
    return f"- {thing}{affirm if option in DEBUG else deny}"

# 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_root(msg)

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

def debug_lazy(msg: Callable[[], Any]) -> None:
    """Write a debug message, depending on configuration."""
    if "debug" in DEBUG:
        debug_root(msg())

# Configure now using defaults.
configure(None)
