#!/usr/bin/env python3
# Copyright: 2026, Maximiliano Curia <maxy@debian.org>
#
# License: ISC
#  Permission to use, copy, modify, and/or distribute this software for any
#  purpose with or without fee is hereby granted, provided that the above
#  copyright notice and this permission notice appear in all copies.
#  .
#  THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH
#  REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
#  AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT,
#  INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
#  LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
#  OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
#  PERFORMANCE OF THIS SOFTWARE.

"""Explain-mode renderer: a DeltaReport rendered as staleness report text.

This is a separate output format from the DEP-5 document Renderer (renderer.py):
it does not implement the `Renderer` protocol and changes for its own reasons, so
it lives in its own module. output.generate_explain drives it.
"""

from __future__ import annotations

import typing as t

from decopy.dep5doc.delta import (
    COPYRIGHT_DRIFT,
    NEW_PARAGRAPH,
    RELICENSED,
    STALE_PARAGRAPH,
    STALE_PATTERN,
)
from decopy.licensing.spdx import render_license_name

if t.TYPE_CHECKING:
    from decopy.common.core_types import Options
    from decopy.dep5doc.delta import DeltaEntry, DeltaReport


def _pluralise(count: int, singular: str, plural: str) -> str:
    return singular if count == 1 else plural


def _change_phrase(was: str, now: str) -> str:
    """The shared `was: X -> now: Y` license-transition phrasing."""
    return f"was: {was} -> now: {now}"


def _item_line(path: str, width: int, phrase: str) -> str:
    """A 4-space-indented item: the path left-padded to `width`, then the phrase.

    Padding to the section's widest path aligns the `was:` column across rows.
    """
    return f"    {path.ljust(width)}  {phrase}"


# A removed-holder copyright drift is advisory (regeneration unions holders and
# never drops one), so it reports but does not fail the gate. The terse tag marks
# the line; a footnote under the section header explains it once.
_ADVISORY_TAG = "[advisory]"
_DRIFT_FOOTNOTE = (
    f"  ({_ADVISORY_TAG} holders are kept on regeneration; does not fail the gate)"
)


# Cap the per-holder source list so a holder in many files stays terminal
# friendly; the overflow collapses to "+N more".
_SOURCE_CAP = 3


def _source_suffix(files: tuple[str, ...] | None) -> str:
    """`  (in a, b)` provenance for an added holder, truncated past the cap."""
    if not files:
        return ""
    shown = files[:_SOURCE_CAP]
    listed = ", ".join(shown)
    extra = len(files) - len(shown)
    if extra:
        listed += f" +{extra} more"
    return f"  (in {listed})"


def _holder_delta_lines(entry: DeltaEntry, removed_suffix: str = "") -> list[str]:
    """Indented `+ added` / `- removed` holder lines, sorted within each side.

    Shared by the copyright-drift block and the relicensed section (copyright
    grouping) so both render holder changes identically. Under license grouping
    the relicensed copyright slots are None, so this yields no lines. An added
    holder names the files it was found in (entry.holder_sources); a removed
    holder is absent from every file, so it carries no source. `removed_suffix`
    lets the drift block tag its removed lines as advisory.
    """
    was_set = set(entry.was_copyright or ())
    now_set = set(entry.now_copyright or ())
    sources = dict(entry.holder_sources or ())
    lines = [
        f"      + {holder}{_source_suffix(sources.get(holder))}"
        for holder in sorted(now_set - was_set)
    ]
    lines += [
        f"      - {holder}{removed_suffix}" for holder in sorted(was_set - now_set)
    ]
    return lines


# A new-paragraph block aligns its field values past the widest label, so Files
# continuation lines sit under the first file.
_FIELD_WIDTH = len("Copyright:")


def _field_lines(label: str, values: list[str]) -> list[str]:
    """A `Label: value` field, value-aligned, with extra values continued below."""
    head = f"    {label.ljust(_FIELD_WIDTH)} {values[0]}"
    pad = "    " + " " * _FIELD_WIDTH + " "
    return [head, *(f"{pad}{value}" for value in values[1:])]


def _holder_field_values(
    holder_sources: tuple[tuple[str, tuple[str, ...]], ...] | None,
) -> list[str]:
    """Each holder with its `(in <file>)` source, sorted, for a Copyright field."""
    return [
        f"{holder}{_source_suffix(files)}"
        for holder, files in sorted(dict(holder_sources or ()).items())
    ]


class ExplainRenderer:
    """Renders a DeltaReport as the explain-mode staleness report text.

    The output owns a fixed section order (new files, relicensed, stale
    paragraphs, stale patterns); empty sections are skipped. Every license is
    passed through render_license_name so --spdx is honoured, and relicensed
    holders are shown only under --group-by copyright.
    """

    CLEAN_MESSAGE = "debian/copyright is up to date."
    LEAD_IN = "debian/copyright is out of date:"

    def explain(self, report: DeltaReport, options: Options) -> str:
        if report.is_empty():
            return f"{self.CLEAN_MESSAGE}\n"
        grouped = report.by_kind()
        sections = [
            self._relicensed_section(grouped.get(RELICENSED, []), options),
            self._copyright_drift_section(grouped.get(COPYRIGHT_DRIFT, []), options),
            self._new_paragraph_section(grouped.get(NEW_PARAGRAPH, []), options),
            self._stale_paragraph_section(grouped.get(STALE_PARAGRAPH, []), options),
            self._stale_pattern_section(grouped.get(STALE_PATTERN, []), options),
        ]
        blocks = [self.LEAD_IN, *(s for s in sections if s)]
        return "\n\n".join(blocks) + "\n"

    def _new_paragraph_section(
        self, entries: list[DeltaEntry], options: Options
    ) -> str:
        entries = [e for e in entries if e.files]
        if not entries:
            return ""
        entries = sorted(entries, key=lambda e: (e.now or "", e.files or ()))
        count = len(entries)
        header = "  {} {} would be added:".format(
            count, _pluralise(count, "new paragraph", "new paragraphs")
        )
        blocks = [self._new_paragraph_block(e, options) for e in entries]
        return "\n\n".join([header, *blocks])

    @classmethod
    def _new_paragraph_block(cls, entry: DeltaEntry, options: Options) -> str:
        """One would-be Files paragraph: its files, License (with source) and,
        when any holder was detected, a Copyright field with per-holder sources.
        """
        license_ = render_license_name(entry.now or "", options)
        source = entry.license_source or ""
        lines = _field_lines("Files:", list(entry.files or ()))
        lines += _field_lines("License:", [f"{license_}  ({source})"])
        holders = _holder_field_values(entry.holder_sources)
        if holders:
            lines += _field_lines("Copyright:", holders)
        return "\n".join(lines)

    def _relicensed_section(self, entries: list[DeltaEntry], options: Options) -> str:
        if not entries:
            return ""
        count = len(entries)
        header = "  {} {}:".format(
            count,
            _pluralise(
                count,
                "file no longer matches its paragraph",
                "files no longer match their paragraph",
            ),
        )
        width = max(len(e.path or "") for e in entries)
        blocks = [self._relicensed_block(e, options, width) for e in entries]
        return "\n".join([header, *blocks])

    @staticmethod
    def _relicensed_block(entry: DeltaEntry, options: Options, width: int) -> str:
        """The relicensed item line plus any holder-delta lines below it."""
        was = render_license_name(entry.was or "", options)
        now = render_license_name(entry.now or "", options)
        line = _item_line(entry.path or "", width, _change_phrase(was, now))
        holders = _holder_delta_lines(entry)
        return "\n".join([line, *holders])

    def _copyright_drift_section(
        self, entries: list[DeltaEntry], options: Options
    ) -> str:
        _ = options
        if not entries:
            return ""
        count = len(entries)
        header = "  {} {}:".format(
            count,
            _pluralise(
                count,
                "paragraph has copyright drift",
                "paragraphs have copyright drift",
            ),
        )
        blocks = [self._drift_block(e) for e in entries]
        # Explain the terse [advisory] tag once, only when one actually appears.
        if any(_ADVISORY_TAG in block for block in blocks):
            return "\n".join([header, _DRIFT_FOOTNOTE, *blocks])
        return "\n".join([header, *blocks])

    @staticmethod
    def _drift_block(entry: DeltaEntry) -> str:
        """The Files line plus the +added / -removed (advisory) holder lines."""
        lines = [
            f"    {line}"
            for line in (entry.paragraph or "").splitlines()
            if line.startswith("Files:")
        ]
        lines += _holder_delta_lines(entry, removed_suffix=f"  {_ADVISORY_TAG}")
        return "\n".join(lines)

    def _stale_paragraph_section(
        self, entries: list[DeltaEntry], options: Options
    ) -> str:
        entries = [e for e in entries if e.paragraph]
        if not entries:
            return ""
        count = len(entries)
        header = "  {} {}:".format(
            count,
            _pluralise(
                count,
                "paragraph matches no files",
                "paragraphs match no files",
            ),
        )
        blocks = [self._stale_paragraph_block(e, options) for e in entries]
        return "\n".join([header, *blocks])

    @staticmethod
    def _stale_paragraph_block(entry: DeltaEntry, options: Options) -> str:
        """The paragraph's Files line(s) with the license as a tag on the last.

        The separate License: line is dropped in favour of a `[License: X]` tag,
        matching the stale-pattern line so the two dead-container views read alike.
        A deleted file that would have matched the paragraph is named beneath it,
        one per line, as the reason it went stale.
        """
        files_lines = [
            f"    {line}"
            for line in (entry.paragraph or "").splitlines()
            if line.startswith("Files:")
        ]
        if files_lines:
            license_ = render_license_name(entry.paragraph_license or "?", options)
            files_lines[-1] += f"  [License: {license_}]"
        culprit_lines = [
            f"      {path} (deleted)" for path in (entry.deleted_files or ())
        ]
        return "\n".join([*files_lines, *culprit_lines])

    def _stale_pattern_section(
        self, entries: list[DeltaEntry], options: Options
    ) -> str:
        if not entries:
            return ""
        count = len(entries)
        header = "  {} {}:".format(
            count, _pluralise(count, "stale pattern", "stale patterns")
        )
        lines = [self._stale_pattern_line(e, options) for e in entries]
        return "\n".join([header, *lines])

    @staticmethod
    def _stale_pattern_line(entry: DeltaEntry, options: Options) -> str:
        license_ = render_license_name(entry.paragraph_license or "?", options)
        return f"    {entry.pattern}  matches nothing  [License: {license_}]"
