"""Basic result reporters."""

import abc
import contextlib
import csv
import json
import typing
from collections import defaultdict
from string import Formatter
from xml.sax.saxutils import escape as xml_escape

from snakeoil.formatters import Formatter as snakeoil_Formatter
from snakeoil.klass import immutable

from . import base
from .results import BaseLinesResult, InvalidResult, Result

T_process_report: typing.TypeAlias = typing.Generator[None, Result, typing.NoReturn]
T_report_func: typing.TypeAlias = typing.Callable[[Result], None]


class ReportFuncShim:
    """Compatibility shim while migrating endusers away from using .report()"""

    __slots__ = ("report",)

    def __init__(self, report: T_report_func) -> None:
        self.report = report

    def __call__(self, result: Result) -> None:
        self.report(result)


class Reporter(abc.ABC, immutable.Simple):
    """Generic result reporter."""

    __slots__ = ("_current_generator", "report")

    priority: int  # used by the config system
    _current_generator: T_process_report | None

    def __init__(self) -> None:
        self._current_generator = None

    @immutable.Simple.__allow_mutation_wrapper__
    def __enter__(self) -> ReportFuncShim:
        self._current_generator = self._consume_reports_generator()
        # start the generator
        next(self._current_generator)
        return ReportFuncShim(self._current_generator.send)

    @immutable.Simple.__allow_mutation_wrapper__
    def __exit__(self, typ, value, traceback):
        # shut down the generator so it can do any finalization
        self._current_generator.close()  # pyright: ignore[reportOptionalMemberAccess]
        self._current_generator = None

    @abc.abstractmethod
    def _consume_reports_generator(self) -> T_process_report:
        """
        This must be a generator consuming from yield to then do something with Results

        Whilst the pattern may seem odd, this is a generator since Reporters have to typically
        keep state between Results- a generator simplifies this.  Simpler code, and faster
        since it's just resuming a generator frame.
        """


class StreamReporter(Reporter):
    __slots__ = ("out",)
    out: snakeoil_Formatter

    def __init__(self, out: snakeoil_Formatter):
        """Initialize

        :type out: L{snakeoil.formatters.Formatter}
        """
        super().__init__()
        self.out = out

    def __enter__(self) -> ReportFuncShim:
        self.out.flush()
        return super().__enter__()

    def __exit__(self, typ, value, traceback) -> None:
        super().__exit__(typ, value, traceback)
        self.out.flush()


class StrReporter(StreamReporter):
    """Simple string reporter, pkgcheck-0.1 behaviour.

    Example::

        sys-apps/portage-2.1-r2: sys-apps/portage-2.1-r2.ebuild has whitespace in indentation on line 169
        sys-apps/portage-2.1-r2: rdepend  ppc-macos: unsolvable default-darwin/macos/10.4, solutions: [ >=app-misc/pax-utils-0.1.13 ]
        sys-apps/portage-2.1-r2: no change in 75 days, keywords [ ~x86-fbsd ]
    """

    __slots__ = ()

    priority = 0

    def _consume_reports_generator(self) -> T_process_report:
        # scope to result prefix mapping
        scope_prefix_map = {
            base.version_scope: "{category}/{package}-{version}: ",
            base.package_scope: "{category}/{package}: ",
            base.category_scope: "{category}: ",
        }

        while True:
            result = yield
            prefix = scope_prefix_map.get(result.scope, "").format(**vars(result))
            self.out.write(f"{prefix}{result.desc}")
            self.out.stream.flush()


class FancyReporter(StreamReporter):
    """Colored output grouped by result scope.

    Example::

        sys-apps/portage
          WrongIndentFound: sys-apps/portage-2.1-r2.ebuild has whitespace in indentation on line 169
          NonsolvableDeps: sys-apps/portage-2.1-r2: rdepend  ppc-macos: unsolvable default-darwin/macos/10.4, solutions: [ >=app-misc/pax-utils-0.1.13 ]
          StableRequest: sys-apps/portage-2.1-r2: no change in 75 days, keywords [ ~x86 ]
    """

    __slots__ = ()
    priority = 1

    def _consume_reports_generator(self) -> T_process_report:
        prev_key = None

        while True:
            result = yield
            if result.scope in (base.version_scope, base.package_scope):
                key = f"{result.category}/{result.package}"
            elif result.scope == base.category_scope:
                key = result.category
            else:
                key = result.scope.desc

            if key != prev_key:
                if prev_key is not None:
                    self.out.write()
                self.out.write(self.out.bold, self.out.fg("blue"), key, self.out.reset)
                prev_key = key
            self.out.first_prefix.append("  ")
            self.out.later_prefix.append("    ")
            s = ""
            if result.scope == base.version_scope:
                s = f"version {result.version}: "
            self.out.write(
                self.out.fg(result.color), result.name, self.out.reset, ": ", s, result.desc
            )
            self.out.first_prefix.pop()
            self.out.later_prefix.pop()
            self.out.stream.flush()


class JsonReporter(StreamReporter):
    """Feed of newline-delimited JSON records.

    Note that the format is newline-delimited JSON with each line being related
    to a separate report. To merge the objects together a tool such as jq can
    be leveraged similar to the following:

    .. code::

        jq -c -s 'reduce.[]as$x({};.*$x)' orig.json > new.json
    """

    __slots__ = ()
    priority = -1000

    def _consume_reports_generator(self) -> T_process_report:
        # arbitrarily nested defaultdicts
        def json_dict():
            return defaultdict(json_dict)

        # scope to data conversion mapping
        scope_map = {
            base.version_scope: lambda data, r: data[r.category][r.package][r.version],
            base.package_scope: lambda data, r: data[r.category][r.package],
            base.category_scope: lambda data, r: data[r.category],
        }

        while True:
            result = yield
            data = json_dict()
            d = scope_map.get(result.scope, lambda x, y: x)(data, result)
            d["_" + result.level][result.name] = result.desc
            self.out.write(json.dumps(data))
            # flush output so partial objects aren't written
            self.out.stream.flush()


class XmlReporter(StreamReporter):
    """Feed of newline-delimited XML reports."""

    __slots__ = ()
    priority = -1000

    def __enter__(self):
        self.out.write("<checks>")
        return super().__enter__()

    def __exit__(self, typ, value, traceback):
        # finalize/close the generator, *then* close the xml.
        ret = super().__exit__(typ, value, traceback)
        self.out.write("</checks>")
        return ret

    def _consume_reports_generator(self) -> T_process_report:
        result_template = "<result><class>%(class)s</class><msg>%(msg)s</msg></result>"
        cat_template = (
            "<result><category>%(category)s</category>"
            "<class>%(class)s</class><msg>%(msg)s</msg></result>"
        )
        pkg_template = (
            "<result><category>%(category)s</category>"
            "<package>%(package)s</package><class>%(class)s</class>"
            "<msg>%(msg)s</msg></result>"
        )
        ver_template = (
            "<result><category>%(category)s</category>"
            "<package>%(package)s</package><version>%(version)s</version>"
            "<class>%(class)s</class><msg>%(msg)s</msg></result>"
        )

        scope_map = {
            base.category_scope: cat_template,
            base.package_scope: pkg_template,
            base.version_scope: ver_template,
        }
        while True:
            result = yield
            d = {k: getattr(result, k, "") for k in ("category", "package", "version")}
            d["class"] = xml_escape(result.name)
            d["msg"] = xml_escape(result.desc)
            self.out.write(scope_map.get(result.scope, result_template) % d)


class CsvReporter(StreamReporter):
    """Comma-separated value reporter, convenient for shell processing.

    Example::

        ,,,"global USE flag 'big-endian' is a potential local, used by 1 package: dev-java/icedtea-bin"
        sys-apps,portage,2.1-r2,sys-apps/portage-2.1-r2.ebuild has whitespace in indentation on line 169
        sys-apps,portage,2.1-r2,"rdepend  ppc-macos: unsolvable default-darwin/macos/10.4, solutions: [ >=app-misc/pax-utils-0.1.13 ]"
        sys-apps,portage,2.1-r2,"no change in 75 days, keywords [ ~x86-fbsd ]"
    """

    __slots__ = ()
    priority = -1001

    def _consume_reports_generator(self) -> T_process_report:
        writer = csv.writer(self.out, doublequote=False, escapechar="\\", lineterminator="")

        while True:
            result = yield
            writer.writerow(
                (
                    getattr(result, "category", ""),
                    getattr(result, "package", ""),
                    getattr(result, "version", ""),
                    result.desc,
                )
            )


class _ResultFormatter(Formatter):
    """Custom string formatter that collapses unmatched variables."""

    def get_value(self, key, args, kwargs):
        """Retrieve a given field value, an empty string is returned for unmatched fields."""
        if isinstance(key, str):
            try:
                return kwargs[key]
            except KeyError:
                return ""
        raise base.PkgcheckUserException("FormatReporter: integer indexes are not supported")


class FormatReporter(StreamReporter):
    """Custom format string reporter.

    This formatter uses custom format string passed using the ``--format``
    command line argument."""

    __slots__ = ("format_str",)
    priority = -1001

    def __init__(self, format_str, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.format_str = format_str

    def _consume_reports_generator(self) -> T_process_report:
        formatter = _ResultFormatter()
        # provide expansions for result desc, level, and output name properties
        properties = ("desc", "level", "name")

        while True:
            result = yield
            attrs = vars(result).copy()
            attrs.update((k, getattr(result, k)) for k in properties)
            s = formatter.format(self.format_str, **attrs)
            # output strings with at least one valid expansion or non-whitespace character
            if s.strip():
                self.out.write(s)
                self.out.stream.flush()


class DeserializationError(Exception):
    """Exception occurred while deserializing a data stream."""


class JsonStream(StreamReporter):
    """Generate a stream of result objects serialized in JSON."""

    __slots__ = ()
    priority = -1001

    @staticmethod
    def to_json(obj) -> str | dict[str, str]:
        """Serialize results and other objects to JSON."""
        if isinstance(obj, Result):
            d = {"__class__": obj.__class__.__name__}
            d.update(obj._attrs)
            return d
        # TODO: remove this pathway via using JSONDecoder with registered decoders.
        # tests for to_json force a cast, so remove that also.
        return str(obj)

    @staticmethod
    def from_iter(iterable) -> typing.Generator[Result, None, None]:
        """Deserialize results from a given iterable."""
        # avoid circular import issues
        from . import objects

        try:
            for data in map(json.loads, iterable):
                cls = objects.KEYWORDS[data.pop("__class__")]
                yield cls._create(**data)
        except (json.decoder.JSONDecodeError, UnicodeDecodeError, DeserializationError) as e:
            raise DeserializationError("failed loading") from e
        except (KeyError, InvalidResult) as e:
            raise DeserializationError("unknown result") from e

    def _consume_reports_generator(self) -> T_process_report:
        while True:
            result = yield
            self.out.write(json.dumps(result, default=self.to_json))


class FlycheckReporter(StreamReporter):
    """Simple line reporter done for easier integration with flycheck [#]_ .

    .. [#] https://github.com/flycheck/flycheck
    """

    __slots__ = ()
    priority = -1001

    def _consume_reports_generator(self) -> T_process_report:
        while True:
            result = yield
            file = f"{getattr(result, 'package', '')}-{getattr(result, 'version', '')}.ebuild"
            message = f"{result.name}: {result.desc}"
            if isinstance(result, BaseLinesResult):
                message = message.replace(result.lines_str, "").strip()
                for lineno in result.lines:
                    self.out.write(f"{file}:{lineno}:{result.level}:{message}")
            else:
                lineno = getattr(result, "lineno", 0)
                self.out.write(f"{file}:{lineno}:{result.level}:{message}")


class CallbackReporter(Reporter):
    """Reporter that calls back for every result"""

    __slots__ = ("callbacks",)
    callbacks: list[T_report_func]

    def __init__(self, *callbacks: T_report_func) -> None:
        self.callbacks = list(callbacks)

    def _consume_reports_generator(self) -> T_process_report:
        while True:
            result = yield
            for callback in self.callbacks:
                callback(result)


class MultiplexingReporter(Reporter):
    """Reporter that multiplexes results to multiple Reporters"""

    __slots__ = ("reporters",)
    reporters: list[Reporter]

    def __init__(self, *args: Reporter) -> None:
        self.reporters = list(args)

    def _consume_reports_generator(self) -> T_process_report:
        with contextlib.ExitStack() as context:
            callbacks = [context.enter_context(reporter) for reporter in self.reporters]
            while True:
                result = yield
                for report_func in callbacks:
                    report_func(result)
