#!/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.

"""Output format as an injectable Renderer.

The document format is an axis decopy is expected to vary (DEP-5 today, perhaps
an SPDX document or a JSON summary later). This module gathers the pieces of a
rendered document behind a single Renderer so the orchestration in output.py can
depend on the abstraction rather than hard-coding DEP-5.

A Renderer produces three pieces; the orchestration owns the surrounding
plumbing (writing, blank-line separators, choosing which groups/licenses to
emit):

  * header(copyright_, options)   - the leading Format/Source paragraph
  * file_block(group, options, file_patterns) - one Files paragraph for a group
  * license_block(license_, options) - one standalone License stanza

Dep5Renderer is the default implementation (DEP-5 copyright-format 1.0); output.py
selects a renderer via renderer_for() and drives it.
"""

from __future__ import annotations

import copy
import typing as t

from decopy.common.core_types import License
from decopy.licensing.license_expr import render_spdx_expression
from decopy.licensing.license_texts import LicenseTextResolver
from decopy.licensing.spdx import dep5_to_spdx, exception_to_spdx, render_license_name

if t.TYPE_CHECKING:
    from decopy.common.core_types import Options
    from decopy.dep5doc.copyright_contracts import CopyrightProtocol
    from decopy.grouping.group_contracts import GroupProtocol


@t.runtime_checkable
class Renderer(t.Protocol):
    """Protocol for rendering the pieces of a copyright document."""

    def header(self, copyright_: CopyrightProtocol | None, options: Options) -> str:
        """Render the leading header paragraph."""
        ...

    def file_block(
        self, group: GroupProtocol, options: Options, file_patterns: list[str]
    ) -> str:
        """Render the Files paragraph for a group.

        `file_patterns` is the already-resolved list of file names/wildcards for
        the Files: field; the caller owns how it was computed (wildcarding,
        cross-group tallying) so the renderer holds no shared state.
        """
        ...

    def license_block(self, license_: License, options: Options) -> str:
        """Render the standalone License stanza for a license."""
        ...


class Dep5Renderer(Renderer):
    """Renders DEP-5 copyright-format 1.0."""

    format_url = "https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/"

    def __init__(self, resolver: LicenseTextResolver | None = None) -> None:
        """`resolver` fills empty License stanzas; None keeps the TODO placeholder."""
        self._resolver = resolver

    def header(self, copyright_: CopyrightProtocol | None, options: Options) -> str:
        if options.scoped_to_subset:
            return "Format: {}\nSource: TODO\nComment: *** only: {} ***".format(
                self.format_url,
                ", ".join(options.files),
            )
        if copyright_:
            # header() is a query: render from a copy so the caller's document
            # is not mutated (and a second render sees the same input). The copy
            # keeps the existing field order; Format is canonicalised and a
            # missing Source defaults to TODO.
            header = copy.deepcopy(copyright_.header)
            header.format = self.format_url  # type: ignore[assignment]  # ty: ignore[invalid-assignment]
            if not header.source:
                header.source = "TODO"
            return (header.dump() or "").rstrip("\n")
        return f"Format: {self.format_url}\nSource: TODO"

    @staticmethod
    def _license_field(group: GroupProtocol, options: Options) -> str:
        """The rendered License field, honouring --spdx.

        With --spdx and a parsed expression, render straight from the AST so
        and/or/comma structure converts faithfully (the string path only knew
        ' or '). Otherwise emit the DEP-5 field, converted by render_license_name.
        """
        if options.spdx and group.license_expr is not None:
            return render_spdx_expression(
                group.license_expr,
                convert_name=dep5_to_spdx,
                convert_exception=lambda label: exception_to_spdx(label) or label,
            )
        return render_license_name(group.license_field, options)

    def file_block(
        self, group: GroupProtocol, options: Options, file_patterns: list[str]
    ) -> str:
        if not group.copyright_block_valid():
            return ""

        files = "\n       ".join(file_patterns)
        block = [f"Files: {files}"]
        if group.copyrights:
            holders = "\n           ".join(group.copyrights.sorted_members())
            block.append(f"Copyright: {holders}")
        block.append(f"License: {self._license_field(group, options)}")
        comments = group.get_comments()
        if comments:
            block.append(f"Comment: {comments}")
        # And finally add all the "unknown" fields carried by the stored
        # paragraph (the group owns which fields count as non-standard).
        for field, value in group.extra_fields():
            block.append(f"{field}: {value}")
        return "\n".join(block)

    def license_block(self, license_: License, options: Options) -> str:
        display = render_license_name(license_.name, options)
        return self._license_stanza(license_, display)

    def _license_stanza(
        self, license_: License, display_name: str | None = None
    ) -> str:
        """Render the DEP-5 License stanza, optionally overriding the name.

        Stored text always wins; otherwise the resolver (when present) fills the
        body from the licence's DEP-5 name, keyed independently of the displayed
        name so --spdx does not change the lookup. Without either, the TODO
        placeholder is kept.
        """
        display = display_name if display_name is not None else license_.name
        stored = self._stored_stanza(license_, display)
        if stored is not None:
            return stored
        if self._resolver is not None:
            body = self._resolver.body(license_.name)
            if body is not None:
                return self._format_body(display, body)
        return f"License: {display}\nComment: Add the corresponding license text here"

    @staticmethod
    def _stored_stanza(license_: License, display: str) -> str | None:
        """The stanza rebuilt from stored text, or None when there is none."""
        if not (license_.stored and (dump := license_.stored.dump()) is not None):
            return None
        body = dump.rstrip("\n")
        newline_pos = body.find("\n")
        if newline_pos >= 0:
            return f"License: {display}{body[newline_pos:]}"
        return f"License: {display}"

    @staticmethod
    def _format_body(display: str, body: str) -> str:
        """Wrap a plain-text body as a DEP-5 License stanza (space-indented, `.`
        for blank lines)."""
        lines = [f"License: {display}"]
        lines += [f" {line}" if line else " ." for line in body.split("\n")]
        return "\n".join(lines)


def renderer_for(options: Options) -> Renderer:
    """Select the output renderer for the run.

    Only DEP-5 copyright-format 1.0 is supported today; this is the single seam
    where a future --output-format option would choose a different Renderer. The
    License-stanza resolver is attached here so --no-fill-licenses restores the
    TODO placeholder.
    """
    resolver = LicenseTextResolver() if options.fill_licenses else None
    return Dep5Renderer(resolver)
