# SPDX-License-Identifier: MIT OR Apache-2.0
# This file is dual licensed under the terms of the Apache License, Version
# 2.0, and the MIT License.  See the LICENSE file in the root of this
# repository for complete details.

"""
Processors and tools specific to the `Twisted <https://twisted.org/>`_
networking engine.

See also :doc:`structlog's Twisted support <twisted>`.
"""

from __future__ import annotations

import json
import sys

from typing import Any, Callable, Sequence, TextIO

from twisted.python import log
from twisted.python.failure import Failure
from twisted.python.log import ILogObserver, textFromEventDict
from zope.interface import implementer

from ._base import BoundLoggerBase
from ._config import _BUILTIN_DEFAULT_PROCESSORS
from ._utils import until_not_interrupted
from .processors import JSONRenderer as GenericJSONRenderer
from .typing import EventDict, WrappedLogger


class BoundLogger(BoundLoggerBase):
    """
    Twisted-specific version of `structlog.BoundLogger`.

    Works exactly like the generic one except that it takes advantage of
    knowing the logging methods in advance.

    Use it like::

        configure(
            wrapper_class=structlog.twisted.BoundLogger,
        )

    """

    def msg(self, event: str | None = None, **kw: Any) -> Any:
        """
        Process event and call ``log.msg()`` with the result.
        """
        return self._proxy_to_logger("msg", event, **kw)

    def err(self, event: str | None = None, **kw: Any) -> Any:
        """
        Process event and call ``log.err()`` with the result.
        """
        return self._proxy_to_logger("err", event, **kw)


class LoggerFactory:
    """
    Build a Twisted logger when an *instance* is called.

    >>> from contrast_vendor.structlog import configure
    >>> from contrast_vendor.structlog.twisted import LoggerFactory
    >>> configure(logger_factory=LoggerFactory())
    """

    def __call__(self, *args: Any) -> WrappedLogger:
        """
        Positional arguments are silently ignored.

        :rvalue: A new Twisted logger.

        .. versionchanged:: 0.4.0
            Added support for optional positional arguments.
        """
        return log


_FAIL_TYPES = (BaseException, Failure)


def _extractStuffAndWhy(eventDict: EventDict) -> tuple[Any, Any, EventDict]:
    """
    Removes all possible *_why*s and *_stuff*s, analyzes exc_info and returns
    a tuple of ``(_stuff, _why, eventDict)``.

    **Modifies** *eventDict*!
    """
    _stuff = eventDict.pop("_stuff", None)
    _why = eventDict.pop("_why", None)
    event = eventDict.pop("event", None)

    if isinstance(_stuff, _FAIL_TYPES) and isinstance(event, _FAIL_TYPES):
        raise ValueError("Both _stuff and event contain an Exception/Failure.")

    # `log.err('event', _why='alsoEvent')` is ambiguous.
    if _why and isinstance(event, str):
        raise ValueError("Both `_why` and `event` supplied.")

    # Two failures are ambiguous too.
    if not isinstance(_stuff, _FAIL_TYPES) and isinstance(event, _FAIL_TYPES):
        _why = _why or "error"
        _stuff = event

    if isinstance(event, str):
        _why = event

    if not _stuff and sys.exc_info() != (None, None, None):
        _stuff = Failure()  # type: ignore[no-untyped-call]

    # Either we used the error ourselves or the user supplied one for
    # formatting.  Avoid log.err() to dump another traceback into the log.
    if isinstance(_stuff, BaseException) and not isinstance(_stuff, Failure):
        _stuff = Failure(_stuff)  # type: ignore[no-untyped-call]

    return _stuff, _why, eventDict


class ReprWrapper:
    """
    Wrap a string and return it as the ``__repr__``.

    This is needed for ``twisted.python.log.err`` that calls `repr` on
    ``_stuff``:

    >>> repr("foo")
    "'foo'"
    >>> repr(ReprWrapper("foo"))
    'foo'

    Note the extra quotes in the unwrapped example.
    """

    def __init__(self, string: str) -> None:
        self.string = string

    def __eq__(self, other: Any) -> bool:
        """
        Check for equality, just for tests.
        """
        return (
            isinstance(other, self.__class__) and self.string == other.string
        )

    def __repr__(self) -> str:
        return self.string


class JSONRenderer(GenericJSONRenderer):
    """
    Behaves like `structlog.processors.JSONRenderer` except that it formats
    tracebacks and failures itself if called with ``err()``.

    .. note::

        This ultimately means that the messages get logged out using ``msg()``,
        and *not* ``err()`` which renders failures in separate lines.

        Therefore it will break your tests that contain assertions using
        `flushLoggedErrors
        <https://docs.twisted.org/en/stable/api/
        twisted.trial.unittest.SynchronousTestCase.html#flushLoggedErrors>`_.

    *Not* an adapter like `EventAdapter` but a real formatter.  Also does *not*
    require to be adapted using it.

    Use together with a `JSONLogObserverWrapper`-wrapped Twisted logger like
    `plainJSONStdOutLogger` for pure-JSON logs.
    """

    def __call__(  # type: ignore[override]
        self,
        logger: WrappedLogger,
        name: str,
        eventDict: EventDict,
    ) -> tuple[Sequence[Any], dict[str, Any]]:
        _stuff, _why, eventDict = _extractStuffAndWhy(eventDict)
        if name == "err":
            eventDict["event"] = _why
            if isinstance(_stuff, Failure):
                eventDict["exception"] = _stuff.getTraceback(detail="verbose")
                _stuff.cleanFailure()  # type: ignore[no-untyped-call]
        else:
            eventDict["event"] = _why
        return (
            (
                ReprWrapper(
                    GenericJSONRenderer.__call__(  # type: ignore[arg-type]
                        self, logger, name, eventDict
                    )
                ),
            ),
            {"_structlog": True},
        )


@implementer(ILogObserver)
class PlainFileLogObserver:
    """
    Write only the the plain message without timestamps or anything else.

    Great to just print JSON to stdout where you catch it with something like
    runit.

    :param file: File to print to.

    .. versionadded:: 0.2.0
    """

    def __init__(self, file: TextIO) -> None:
        self._write = file.write
        self._flush = file.flush

    def __call__(self, eventDict: EventDict) -> None:
        until_not_interrupted(
            self._write,
            textFromEventDict(eventDict)  # type: ignore[arg-type, operator]
            + "\n",
        )
        until_not_interrupted(self._flush)


@implementer(ILogObserver)
class JSONLogObserverWrapper:
    """
    Wrap a log *observer* and render non-`JSONRenderer` entries to JSON.

    :param ILogObserver observer: Twisted log observer to wrap.  For example
        :class:`PlainFileObserver` or Twisted's stock `FileLogObserver
        <https://docs.twisted.org/en/stable/api/
        twisted.python.log.FileLogObserver.html>`_

    .. versionadded:: 0.2.0
    """

    def __init__(self, observer: Any) -> None:
        self._observer = observer

    def __call__(self, eventDict: EventDict) -> str:
        if "_structlog" not in eventDict:
            eventDict["message"] = (
                json.dumps(
                    {
                        "event": textFromEventDict(
                            eventDict  # type: ignore[arg-type]
                        ),
                        "system": eventDict.get("system"),
                    }
                ),
            )
            eventDict["_structlog"] = True

        return self._observer(eventDict)


def plainJSONStdOutLogger() -> JSONLogObserverWrapper:
    """
    Return a logger that writes only the message to stdout.

    Transforms non-`JSONRenderer` messages to JSON.

    Ideal for JSONifying log entries from Twisted plugins and libraries that
    are outside of your control::

        $ twistd -n --logger structlog.twisted.plainJSONStdOutLogger web
        {"event": "Log opened.", "system": "-"}
        {"event": "twistd 13.1.0 (python 2.7.3) starting up.", "system": "-"}
        {"event": "reactor class: twisted...EPollReactor.", "system": "-"}
        {"event": "Site starting on 8080", "system": "-"}
        {"event": "Starting factory <twisted.web.server.Site ...>", ...}
        ...

    Composes `PlainFileLogObserver` and `JSONLogObserverWrapper` to a usable
    logger.

    .. versionadded:: 0.2.0
    """
    return JSONLogObserverWrapper(PlainFileLogObserver(sys.stdout))


class EventAdapter:
    """
    Adapt an ``event_dict`` to Twisted logging system.

    Particularly, make a wrapped `twisted.python.log.err
    <https://docs.twisted.org/en/stable/api/twisted.python.log.html#err>`_
    behave as expected.

    :param dictRenderer: Renderer that is used for the actual log message.
        Please note that structlog comes with a dedicated `JSONRenderer`.

    **Must** be the last processor in the chain and requires a *dictRenderer*
    for the actual formatting as an constructor argument in order to be able to
    fully support the original behaviors of ``log.msg()`` and ``log.err()``.
    """

    def __init__(
        self,
        dictRenderer: Callable[[WrappedLogger, str, EventDict], str]
        | None = None,
    ) -> None:
        """
        :param dictRenderer: A processor used to format the log message.
        """
        self._dictRenderer = dictRenderer or _BUILTIN_DEFAULT_PROCESSORS[-1]

    def __call__(
        self, logger: WrappedLogger, name: str, eventDict: EventDict
    ) -> Any:
        if name == "err":
            # This aspires to handle the following cases correctly:
            #   - log.err(failure, _why='event', **kw)
            #   - log.err('event', **kw)
            #   - log.err(_stuff=failure, _why='event', **kw)
            _stuff, _why, eventDict = _extractStuffAndWhy(eventDict)
            eventDict["event"] = _why

            return (
                (),
                {
                    "_stuff": _stuff,
                    "_why": self._dictRenderer(logger, name, eventDict),
                },
            )

        return self._dictRenderer(logger, name, eventDict)
