import atexit
import logging
import warnings
from collections import defaultdict
from contextlib import contextmanager
from dataclasses import dataclass
from logging import LogRecord
from pprint import pformat
from types import TracebackType
from typing import (
    Any, Callable, Iterator, Sequence, TypeAlias, TypeVar, List, Tuple, Self, Protocol, overload
)
from warnings import warn

from .comparing import compare
from .comparison import SequenceComparison
from .utils import wrap


class CaptureSource(Protocol):
    """
    Protocol that :class:`LogCapture` sources must implement.
    """

    def install(self, collector: Callable[['Entry'], None]) -> None:
        """
        Start capturing.

        :param collector: The destination for captured :attr:`~testfixtures.LogCapture.entries`.
        """
        ...

    def uninstall(self) -> None:
        """Stop capturing and restore any previous state."""
        ...



T = TypeVar('T')
AttributeSpec: TypeAlias = Sequence[str | Callable[[T], Any]] | Callable[[T], Any]


def build_actual_extractor(
    attributes: AttributeSpec[T],
    extract_field: Callable[[T, str], Any],
) -> Callable[[T], Any]:
    # Compile the attributes spec into a callable that extracts an entry's
    # ``actual`` value from a raw record. A bare string is treated as a single
    # attribute name (``str`` is itself a ``Sequence[str]``).
    if callable(attributes):
        return attributes
    if isinstance(attributes, str):
        attributes = (attributes,)
    attrs = tuple(attributes)
    if len(attrs) == 1:
        only = attrs[0]
        if callable(only):
            return only
        return lambda raw: extract_field(raw, only)
    return lambda raw: tuple(
        a(raw) if callable(a) else extract_field(raw, a) for a in attrs
    )


@dataclass
class Entry:
    """
    A captured log entry captured by a :class:`~testfixtures.logcapture.CaptureSource`.
    """

    #: The raw object delivered by the logging framework. This is a :class:`~logging.LogRecord`
    #: for standard library logging.
    raw: Any
    #: The extracted value used by :meth:`~testfixtures.LogCapture.check` and related methods.
    #: Its structure is determined by the ``attributes`` parameter of the source.
    actual: Any
    #: Numeric log level used by :meth:`~testfixtures.LogCapture.ensure_checked`, or ``None``
    #: if the source does not provide a comparable numeric level.
    level: int | None
    #: The exception associated with this entry if one was captured, otherwise ``None``.
    exception: BaseException | None = None
    #: Whether this entry has been marked as checked by a :meth:`~testfixtures.LogCapture.check`
    #: call.
    checked: bool = False

LogRecordAttributes: TypeAlias = AttributeSpec[LogRecord]


class LogCapture:
    """
    Captures log entries from one or more sources and provides methods to check what was logged.

    :param sources:
        One or more :class:`capture sources <testfixtures.logcapture.CaptureSource>`
    :param install:
        If ``True`` (the default), capturing starts immediately on construction.
        If ``False``, installation is deferred until :meth:`install` is called.
    :param recursive_check:
        If ``True``, entries are compared recursively by :meth:`check`.
    :param ensure_checks_above:
        The log level above which entries must have been checked.
        See :meth:`ensure_checked`.

    For compatibility with earlier versions, capturing only
    standard library :mod:`logging` is supported by instantiating using these parameters:

    :param names:
        A string (or tuple of strings) containing the dotted name(s) of loggers to capture.
        By default, the root logger is captured.
    :param level:
        The minimum log level to capture.  Defaults to ``1``, capturing everything.
    :param propagate:
        If specified, any captured loggers will have their propagate attribute set to the supplied
        value. This can be used to prevent propagation from a child logger to a parent logger that
        has configured handlers.
    :param attributes:
        The sequence of attributes to return for each record or a callable that extracts
        :attr:`~testfixtures.logcapture.Entry.actual` from a record.

        If a sequence of attribute names is passed, for each item, an attribute of that name will
        be obtained from the :class:`logging.LogRecord`. If the attribute is callable, the result of
        calling it will be used as the attribute value. If an attribute is missing, ``None`` will be
        used in its place.

        If a callable is passed, it will be called with the :class:`~logging.LogRecord` and the
        value returned will be used as :attr:`~testfixtures.logcapture.Entry.actual`.
    """

    #: The list of :class:`entries <testfixtures.logcapture.Entry>` captured so far.
    entries: list[Entry]

    @property
    def records(self) -> List[LogRecord]:
        """
        The records captured by this :class:`LogCapture`.

        .. deprecated:: 12
            Use the :attr:`entries` attribute instead.
        """
        return [e.raw for e in self.entries if isinstance(e.raw, LogRecord)]


    instances: set['LogCapture'] = set()
    atexit_setup = False
    installed = False

    ensure_checks_above: int | None
    #: Class-level default for :meth:`ensure_checked`. Set this to apply a level
    #: threshold to all :class:`LogCapture` instances that do not override it explicitly.
    default_ensure_checks_above: int | None = None

    @overload
    def __init__(
        self,
        *sources: CaptureSource,
        install: bool = True,
        recursive_check: bool = False,
        ensure_checks_above: int | None = None,
    ) -> None: ...

    @overload
    def __init__(
        self,
        names: str | Tuple[str | None, ...] | None = None,
        *,
        install: bool = True,
        level: int = 1,
        propagate: bool | None = None,
        attributes: LogRecordAttributes = ...,
        recursive_check: bool = False,
        ensure_checks_above: int | None = None,
    ) -> None: ...

    def __init__(  # type: ignore[misc]
        self,
        *args: Any,
        install: bool = True,
        level: int = 1,
        propagate: bool | None = None,
        attributes: LogRecordAttributes = (
            'name',
            'levelname',
            'getMessage',
        ),
        recursive_check: bool = False,
        ensure_checks_above: int | None = None,
    ) -> None:
        if args and hasattr(args[0], 'install'):
            self._sources = list(args)
        else:
            names: str | Tuple[str | None, ...] | None = args[0] if args else None
            if not isinstance(names, tuple):
                names = (names,)
            self._sources = [LoggingSource(attributes, level, names=names, propagate=propagate)]
        self.recursive_check = recursive_check
        self._disabled = False
        if ensure_checks_above is None:
            self.ensure_checks_above = self.default_ensure_checks_above
        else:
            self.ensure_checks_above = ensure_checks_above
        self.clear()
        if install:
            self.install()

    @classmethod
    def atexit(cls) -> None:
        if cls.instances:
            warnings.warn(
                'LogCapture instances not uninstalled by shutdown, '
                'sources captured:\n'
                '%s' % ('\n'.join((', '.join(repr(s) for s in i._sources) for i in cls.instances)))
            )

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

    def __getitem__(self, index: int) -> Any:
        return self.entries[index].actual

    def __contains__(self, what: Any) -> bool:
        for i, entry in enumerate(self.entries):
            if what == entry.actual:
                self.entries[i].checked = True
                return True
        return False

    def clear(self) -> None:
        """Clear any entries that have been captured."""
        self.entries: list[Entry] = []

    def mark_all_checked(self) -> None:
        """
        Mark all captured events as checked.
        This should be called if you have made assertions about logging
        other than through :class:`LogCapture` methods.
        """
        for entry in self.entries:
            entry.checked = True

    def ensure_checked(self, level: int | None = None) -> None:
        """
        Ensure every entry logged above the specified `level` has been checked.
        Raises an :class:`AssertionError` if this is not the case.

        :param level:
           This defaults to the level passed during :class:`LogCapture` instantiation.
        """
        threshold: int | None = level if level is not None else self.ensure_checks_above
        if threshold is None:
            return
        un_checked = []
        for entry in self.entries:
            if entry.level is not None and entry.level >= threshold and not entry.checked:
                un_checked.append(entry.actual)
        if un_checked:
            raise AssertionError(('Not asserted ERROR log(s): %s') % (pformat(un_checked)))

    def _collect_entry(self, entry: Entry) -> None:
        self.entries.append(entry)

    @contextmanager
    def disabled(self) -> Iterator[None]:
        """
        A context manager that stops capturing for the duration of the ``with`` block by
        uninstalling the capture and reinstalling it on exit. Anything logged while it is
        active is handled as if the capture were not present, which is useful for ignoring
        noisy setup in a test.
        """
        self.uninstall()
        try:
            yield
        finally:
            self.install()

    def install(self) -> Self | None:
        """
        Install this :class:`LogCapture`, enabling all configured sources to begin capturing.
        """
        for source in self._sources:
            source.install(self._collect_entry)
        self.instances.add(self)
        if not self.__class__.atexit_setup:
            atexit.register(self.atexit)
            self.__class__.atexit_setup = True
        return None

    def uninstall(self) -> None:
        """
        Uninstall this :class:`LogCapture`, restoring all sources to their previous state.
        """
        if self in self.instances:
            for source in self._sources:
                source.uninstall()
            self.instances.remove(self)

    @classmethod
    def uninstall_all(cls) -> None:
        "This will uninstall all existing :class:`LogCapture` objects."
        for i in tuple(cls.instances):
            i.uninstall()

    def actual(self) -> list[Any]:
        """
        The sequence of :attr:`~testfixtures.logcapture.Entry.actual` items captured.

        This can be useful for making more complex assertions about captured logging.
        The full :class:`~testfixtures.logcapture.Entry` objects captured for each logging call
        can be accessed through :attr:`entries`.
        """
        return [e.actual for e in self.entries]

    def __str__(self) -> str:
        if not self.entries:
            return 'No logging captured'
        tuples = (r if isinstance(r, tuple) else (r,) for r in self.actual())
        return '\n'.join(' '.join(str(e) for e in t) for t in tuples)

    def check(
        self,
        *expected: Any,
        order_matters: bool = True,
        raises: bool = True,
        level: int | None = None,
        predicate: Callable[[Entry], bool] | None = None,
    ) -> str | None:
        """
        This will compare the captured entries with the expected
        entries provided and raise an :class:`AssertionError` if they
        do not match.

        :param expected:

          A sequence of expected :attr:`~testfixtures.logcapture.Entry.actual` items.

        :param order_matters:

          A keyword-only parameter that controls whether the order of the
          captured entries is required to match those of the expected entries.
          Defaults to ``True``.

        :param raises: If ``False``, the message that would be raised in the
                       :class:`AssertionError` will be returned instead of the
                       exception being raised.

        :param level:

          A keyword-only parameter that, if provided, excludes any captured entry
          with a numeric :attr:`~testfixtures.logcapture.Entry.level` below it from
          the comparison. Entries with a ``level`` of ``None`` are always included.

        :param predicate:

          A keyword-only parameter that, if provided, is called with each captured
          :class:`~testfixtures.logcapture.Entry`; only those for which it returns
          a true value are included in the comparison.
        """
        __tracebackhide__ = True

        actual = []
        for entry in self.entries:
            if level is not None and entry.level is not None and entry.level < level:
                continue
            if predicate is not None and not predicate(entry):
                continue
            entry.checked = True
            actual.append(entry.actual)

        result = None
        if order_matters:
            result = compare(
                expected, actual=actual, recursive=self.recursive_check, raises=False
            )
        else:
            expected_ = SequenceComparison(
                *expected, ordered=False, partial=False, recursive=self.recursive_check
            )
            if expected_ != actual:
                result = expected_.failed
        if result and raises:
            raise AssertionError(result)
        return result

    def check_empty(
        self,
        level: int | None = None,
        predicate: Callable[[Entry], bool] | None = None,
    ) -> None:
        """
        Assert that nothing was logged, raising an :class:`AssertionError` if it was.
        This is a clearer way of writing :meth:`check` with no expected entries.

        :param level:

          If provided, only entries with a numeric
          :attr:`~testfixtures.logcapture.Entry.level` at or above it are considered.
          Entries with a ``level`` of ``None`` are always considered.

        :param predicate:

          If provided, it is called with each captured
          :class:`~testfixtures.logcapture.Entry`; only those for which it returns
          a true value are considered.
        """
        __tracebackhide__ = True
        self.check(level=level, predicate=predicate)

    def raise_first_exception(self, start_index: int = 0) -> None:
        """
        Raise the first captured exception from ``start_index`` onwards.

        :param start_index: The index into :attr:`entries` from where to start looking.
        """
        for entry in self.entries[start_index:]:
            if entry.exception is not None:
                raise entry.exception

    def check_exception_str(self, expected: str, index: int = -1) -> None:
        """
        Check the string representation of a captured exception.

        :param expected: The expected string.
        :param index: The index into :attr:`entries` where the exception should have been captured.
        """
        compare(expected, actual=str(self.entries[index].exception))

    def check_present(
            self, *expected: Any, order_matters: bool = True, raises: bool = True
    ) -> str | None:
        """
        This will check if the captured attr:`entries` contain all of the expected
        entries provided and raise an :class:`AssertionError` if not.
        This will ignore entries that have been captured but that do not
        match those in ``expected``.

        :param expected:

          A sequence of expected :attr:`~testfixtures.logcapture.Entry.actual` items.

        :param order_matters:

          A keyword-only parameter that controls whether the order of the
          captured entries is required to match those of the expected entries.
          Defaults to ``True``.

        :param raises: If ``False``, the message that would be raised in the
                       :class:`AssertionError` will be returned instead of the
                       exception being raised.
        """
        __tracebackhide__ = True
        actual = self.actual()
        expected_ = SequenceComparison(
            *expected, ordered=order_matters, partial=True, recursive=self.recursive_check
        )
        if expected_ != actual:
            if raises:
                raise AssertionError(expected_.failed)
            return expected_.failed
        for index in expected_.checked_indices:
            self.entries[index].checked = True
        return None

    def __enter__(self) -> Self:
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        value: BaseException | None,
        traceback: TracebackType | None,
    ) -> None:
        self.uninstall()
        self.ensure_checked()


class LogCaptureForDecorator(LogCapture):
    def install(self) -> Self:
        LogCapture.install(self)
        self.clear()
        return self


def log_capture(*names: str, **kw: Any) -> Callable:
    """
    A decorator for making a :class:`LogCapture` installed and
    available for the duration of a test function.

    :param names: An optional sequence of names specifying the loggers
                  to be captured. If not specified, the root logger
                  will be captured.

    Keyword parameters other than ``install`` may also be supplied and will be
    passed on to the :class:`LogCapture` constructor.
    """
    l = LogCaptureForDecorator(names or None, install=False, **kw)
    return wrap(l.install, l.uninstall)


class LoggingHandler(logging.Handler):
    """Minimal handler that forwards records to a :class:`LoggingSource`."""

    def __init__(self, source: 'LoggingSource') -> None:
        super().__init__()
        self._source = source

    def __bool__(self) -> bool:
        # Some logging internals check boolean rather than identity for handlers :-(
        return True

    def emit(self, record: LogRecord) -> None:
        self._source._handle_record(record)

    def close(self) -> None:
        super().close()
        self._source._handler_closed()


class LoggingSource:
    """
    A :class:`~testfixtures.logcapture.CaptureSource` for the standard-library
    :mod:`logging` framework, for use with :class:`~testfixtures.LogCapture`.

    :param attributes:
        The sequence of attributes to return for each record or a callable that extracts
        :attr:`~testfixtures.logcapture.Entry.actual` from a record.

        If a sequence of attribute names is passed, for each item, an attribute of that name will
        be obtained from the :class:`logging.LogRecord`. If the attribute is callable, the result of
        calling it will be used as the attribute value. If an attribute is missing, ``None`` will be
        used in its place.

        If a callable is passed, it will be called with the :class:`~logging.LogRecord` and the
        value returned will be used as :attr:`~testfixtures.logcapture.Entry.actual`.
    :param level:
        The minimum log level to capture.  Defaults to ``1``, capturing everything.
    :param names:
        A string (or tuple of strings) containing the dotted name(s) of loggers to capture.
        By default, the root logger is captured.
    :param propagate:
        If specified, any captured loggers will have their propagate attribute set to the supplied
        value. This can be used to prevent propagation from a child logger to a parent logger that
        has configured handlers.
    """

    def __init__(
        self,
        attributes: LogRecordAttributes = ('levelname', 'getMessage'),
        level: int = 1,
        *,
        names: Tuple[str | None, ...] = (None,),
        propagate: bool | None = None,
    ) -> None:
        self.attributes = attributes
        self.level = level
        self.names = names
        self.propagate = propagate
        self.old: dict[str, dict[str | None, Any]] = defaultdict(dict)
        self._collector: Callable[[Entry], None] | None = None
        self._handler: LoggingHandler | None = None
        self._compute_actual = build_actual_extractor(attributes, self.extract_field)

    def extract_field(self, raw: LogRecord, attribute: str) -> Any:
        value = getattr(raw, attribute, None)
        if callable(value):
            value = value()
        return value

    def __repr__(self) -> str:
        return f'LoggingSource({self.names!r})'

    def _handler_closed(self) -> None:
        if self._collector is not None:
            warn(
                f'LoggingSource closed while still capturing loggers: {self.names!r}\n'
                'Call uninstall() or use LogCapture as a context manager.'
            )

    def _handle_record(self, record: LogRecord) -> None:
        if self._collector is None:
            return
        exception = record.exc_info[1] if record.exc_info else None
        self._collector(Entry(
            raw=record,
            actual=self._compute_actual(record),
            level=record.levelno,
            exception=exception,
        ))

    def install(self, collector: Callable[[Entry], None]) -> None:
        self._collector = collector
        self._handler = LoggingHandler(self)
        for name in self.names:
            logger = logging.getLogger(name)
            self.old['levels'][name] = logger.level
            self.old['filters'][name] = logger.filters
            self.old['handlers'][name] = logger.handlers
            self.old['disabled'][name] = logger.disabled
            self.old['propagate'][name] = logger.propagate
            logger.setLevel(self.level)
            logger.filters = []
            logger.handlers = [self._handler]
            logger.disabled = False
            if self.propagate is not None:
                logger.propagate = self.propagate

    def uninstall(self) -> None:
        for name in self.names:
            logger = logging.getLogger(name)
            logger.setLevel(self.old['levels'][name])
            logger.filters = self.old['filters'][name]
            logger.handlers = self.old['handlers'][name]
            logger.disabled = self.old['disabled'][name]
            logger.propagate = self.old['propagate'][name]
        self._handler = None
        self._collector = None
