# -*- test-case-name: twisted.logger.test.test_format -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.

"""
Tools for formatting logging events.
"""

from __future__ import annotations

from collections.abc import Iterator, Mapping
from datetime import datetime as DateTime
from typing import Any, Callable, Optional, Union, cast

from twisted.python._tzhelper import FixedOffsetTimeZone
from twisted.python.failure import Failure
from twisted.python.reflect import safe_repr
from ._flatten import aFormatter, flatFormat
from ._interfaces import LogEvent

timeFormatRFC3339 = "%Y-%m-%dT%H:%M:%S%z"


def formatEvent(event: LogEvent) -> str:
    """
    Formats an event as text, using the format in C{event["log_format"]}.

    This implementation should never raise an exception; if the formatting
    cannot be done, the returned string will describe the event generically so
    that a useful message is emitted regardless.

    @param event: A logging event.

    @return: A formatted string.
    """
    return eventAsText(
        event,
        includeTraceback=False,
        includeTimestamp=False,
        includeSystem=False,
    )


def formatUnformattableEvent(event: LogEvent, error: BaseException) -> str:
    """
    Formats an event as text that describes the event generically and a
    formatting error.

    @param event: A logging event.
    @param error: The formatting error.

    @return: A formatted string.
    """
    try:
        return "Unable to format event {event!r}: {error}".format(
            event=event, error=error
        )
    except BaseException:
        # Yikes, something really nasty happened.
        #
        # Try to recover as much formattable data as possible; hopefully at
        # least the namespace is sane, which will help you find the offending
        # logger.
        failure = Failure()

        text = ", ".join(
            " = ".join((safe_repr(key), safe_repr(value)))
            for key, value in event.items()
        )

        return (
            "MESSAGE LOST: unformattable object logged: {error}\n"
            "Recoverable data: {text}\n"
            "Exception during formatting:\n{failure}".format(
                error=safe_repr(error), failure=failure, text=text
            )
        )


def formatTime(
    when: float | None,
    timeFormat: str | None = timeFormatRFC3339,
    default: str = "-",
) -> str:
    """
    Format a timestamp as text.

    Example::

        >>> from time import time
        >>> from twisted.logger import formatTime
        >>>
        >>> t = time()
        >>> formatTime(t)
        u'2013-10-22T14:19:11-0700'
        >>> formatTime(t, timeFormat="%Y/%W")  # Year and week number
        u'2013/42'
        >>>

    @param when: A timestamp.
    @param timeFormat: A time format.
    @param default: Text to return if C{when} or C{timeFormat} is L{None}.

    @return: A formatted time.
    """
    if timeFormat is None or when is None:
        return default
    else:
        tz = FixedOffsetTimeZone.fromLocalTimeStamp(when)
        datetime = DateTime.fromtimestamp(when, tz)
        return str(datetime.strftime(timeFormat))


def formatEventAsClassicLogText(
    event: LogEvent, formatTime: Callable[[float | None], str] = formatTime
) -> str | None:
    """
    Format an event as a line of human-readable text for, e.g. traditional log
    file output.

    The output format is C{"{timeStamp} [{system}] {event}\\n"}, where:

        - C{timeStamp} is computed by calling the given C{formatTime} callable
          on the event's C{"log_time"} value

        - C{system} is the event's C{"log_system"} value, if set, otherwise,
          the C{"log_namespace"} and C{"log_level"}, joined by a C{"#"}.  Each
          defaults to C{"-"} is not set.

        - C{event} is the event, as formatted by L{formatEvent}.

    Example::

        >>> from time import time
        >>> from twisted.logger import formatEventAsClassicLogText
        >>> from twisted.logger import LogLevel
        >>>
        >>> formatEventAsClassicLogText(dict())  # No format, returns None
        >>> formatEventAsClassicLogText(dict(log_format="Hello!"))
        u'- [-#-] Hello!\\n'
        >>> formatEventAsClassicLogText(dict(
        ...     log_format="Hello!",
        ...     log_time=time(),
        ...     log_namespace="my_namespace",
        ...     log_level=LogLevel.info,
        ... ))
        u'2013-10-22T17:30:02-0700 [my_namespace#info] Hello!\\n'
        >>> formatEventAsClassicLogText(dict(
        ...     log_format="Hello!",
        ...     log_time=time(),
        ...     log_system="my_system",
        ... ))
        u'2013-11-11T17:22:06-0800 [my_system] Hello!\\n'
        >>>

    @param event: an event.
    @param formatTime: A time formatter

    @return: A formatted event, or L{None} if no output is appropriate.
    """
    eventText = eventAsText(event, formatTime=formatTime)
    if not eventText:
        return None
    eventText = eventText.replace("\n", "\n\t")
    return eventText + "\n"


def keycall(key: str, getter: Callable[[str], Any]) -> PotentialCallWrapper:
    """
    Check to see if C{key} ends with parentheses ("C{()}"); if not, wrap up the
    result of C{get} in a L{PotentialCallWrapper}.  Otherwise, call the result
    of C{get} first, before wrapping it up.

    @param key: The last dotted segment of a formatting key, as parsed by
        L{string.Formatter.vformat}, which may end in C{()}.

    @param getter: A function which takes a string and returns some other
        object, to be formatted and stringified for a log.

    @return: A L{PotentialCallWrapper} that will wrap up the result to allow
        for subsequent usages of parens to defer execution to log-format time.
    """
    callit = key.endswith("()")
    realKey = key[:-2] if callit else key
    value = getter(realKey)
    if callit:
        value = value()
    return PotentialCallWrapper(value)


class PotentialCallWrapper:
    """
    Object wrapper that wraps C{getattr()} so as to process call-parentheses
    C{"()"} after a dotted attribute access.
    """

    def __init__(self, wrapped: object) -> None:
        self._wrapped = wrapped

    def __getattr__(self, name: str) -> object:
        return keycall(name, lambda name_: getattr(self._wrapped, name_))

    def __getitem__(self, name: str) -> object:
        # The sub-object may not be indexable, but if it isn't, that's the
        # caller's problem.
        value = self._wrapped[name]  # type:ignore[index]
        return PotentialCallWrapper(value)

    def __format__(self, format_spec: str) -> str:
        return format(self._wrapped, format_spec)

    def __repr__(self) -> str:
        return repr(self._wrapped)

    def __str__(self) -> str:
        return str(self._wrapped)


class CallMapping(Mapping[str, Any]):
    """
    Read-only mapping that turns a C{()}-suffix in key names into an invocation
    of the key rather than a lookup of the key.

    Implementation support for L{formatWithCall}.
    """

    def __init__(self, submapping: Mapping[str, Any]) -> None:
        """
        @param submapping: Another read-only mapping which will be used to look
            up items.
        """
        self._submapping = submapping

    def __iter__(self) -> Iterator[Any]:
        return iter(self._submapping)

    def __len__(self) -> int:
        return len(self._submapping)

    def __getitem__(self, key: str) -> Any:
        """
        Look up an item in the submapping for this L{CallMapping}, calling it
        if C{key} ends with C{"()"}.
        """
        return keycall(key, self._submapping.__getitem__)


def formatWithCall(formatString: str, mapping: Mapping[str, Any]) -> str:
    """
    Format a string like L{str.format}, but:

        - taking only a name mapping; no positional arguments

        - with the additional syntax that an empty set of parentheses
          correspond to a formatting item that should be called, and its result
          C{str}'d, rather than calling C{str} on the element directly as
          normal.

    For example::

        >>> formatWithCall("{string}, {function()}.",
        ...                dict(string="just a string",
        ...                     function=lambda: "a function"))
        'just a string, a function.'

    @param formatString: A PEP-3101 format string.
    @param mapping: A L{dict}-like object to format.

    @return: The string with formatted values interpolated.
    """
    return str(aFormatter.vformat(formatString, (), CallMapping(mapping)))


def _formatEvent(event: LogEvent) -> str:
    """
    Formats an event as a string, using the format in C{event["log_format"]}.

    This implementation should never raise an exception; if the formatting
    cannot be done, the returned string will describe the event generically so
    that a useful message is emitted regardless.

    @param event: A logging event.

    @return: A formatted string.
    """
    try:
        if "log_flattened" in event:
            return flatFormat(event)

        format = cast(Optional[Union[str, bytes]], event.get("log_format", None))
        if format is None:
            return ""

        # Make sure format is text.
        if isinstance(format, str):
            pass
        elif isinstance(format, bytes):
            format = format.decode("utf-8")
        else:
            raise TypeError(f"Log format must be str, not {format!r}")

        return formatWithCall(format, event)

    except BaseException as e:
        return formatUnformattableEvent(event, e)


def _formatTraceback(failure: Failure) -> str:
    """
    Format a failure traceback, assuming UTF-8 and using a replacement
    strategy for errors.  Every effort is made to provide a usable
    traceback, but should not that not be possible, a message and the
    captured exception are logged.

    @param failure: The failure to retrieve a traceback from.

    @return: The formatted traceback.
    """
    try:
        traceback = failure.getTraceback()
    except BaseException as e:
        traceback = "(UNABLE TO OBTAIN TRACEBACK FROM EVENT):" + str(e)
    return traceback


def _formatSystem(event: LogEvent) -> str:
    """
    Format the system specified in the event in the "log_system" key if set,
    otherwise the C{"log_namespace"} and C{"log_level"}, joined by a C{"#"}.
    Each defaults to C{"-"} is not set.  If formatting fails completely,
    "UNFORMATTABLE" is returned.

    @param event: The event containing the system specification.

    @return: A formatted string representing the "log_system" key.
    """
    system = cast(Optional[str], event.get("log_system", None))
    if system is None:
        level = event.get("log_level", None)
        if level is None:
            levelName = "-"
        else:
            levelName = level.name

        system = "{namespace}#{level}".format(
            namespace=cast(str, event.get("log_namespace", "-")),
            level=levelName,
        )
    else:
        try:
            system = str(system)
        except Exception:
            system = "UNFORMATTABLE"
    return system


def eventAsText(
    event: LogEvent,
    includeTraceback: bool = True,
    includeTimestamp: bool = True,
    includeSystem: bool = True,
    formatTime: Callable[[float], str] = formatTime,
) -> str:
    r"""
    Format an event as text.  Optionally, attach timestamp, traceback, and
    system information.

    The full output format is:
    C{"{timeStamp} [{system}] {event}\n{traceback}\n"} where:

        - C{timeStamp} is the event's C{"log_time"} value formatted with
          the provided C{formatTime} callable.

        - C{system} is the event's C{"log_system"} value, if set, otherwise,
          the C{"log_namespace"} and C{"log_level"}, joined by a C{"#"}.  Each
          defaults to C{"-"} is not set.

        - C{event} is the event, as formatted by L{formatEvent}.

        - C{traceback} is the traceback if the event contains a
          C{"log_failure"} key.  In the event the original traceback cannot
          be formatted, a message indicating the failure will be substituted.

    If the event cannot be formatted, and no traceback exists, an empty string
    is returned, even if includeSystem or includeTimestamp are true.

    @param event: A logging event.
    @param includeTraceback: If true and a C{"log_failure"} key exists, append
        a traceback.
    @param includeTimestamp: If true include a formatted timestamp before the
        event.
    @param includeSystem:  If true, include the event's C{"log_system"} value.
    @param formatTime: A time formatter

    @return: A formatted string with specified options.

    @since: Twisted 18.9.0
    """
    eventText = _formatEvent(event)
    if includeTraceback and "log_failure" in event:
        f = event["log_failure"]
        traceback = _formatTraceback(f)
        eventText = "\n".join((eventText, traceback))

    if not eventText:
        return eventText

    timeStamp = ""
    if includeTimestamp:
        timeStamp = "".join([formatTime(cast(float, event.get("log_time", None))), " "])

    system = ""
    if includeSystem:
        system = "".join(["[", _formatSystem(event), "]", " "])

    return "{timeStamp}{system}{eventText}".format(
        timeStamp=timeStamp,
        system=system,
        eventText=eventText,
    )
