#!/usr/bin/env python3
# Copyright: 2016, 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.

"""Group management and processing"""

from __future__ import annotations

import collections.abc as abc
import os
import typing as t

import debian.copyright as dc

from decopy.common.core_types import (
    UNKNOWN,
    UNKNOWN_COPYRIGHTED,
    CopyrightHolder,
    License,
    Options,
)
from decopy.fstree.nodes import FileInfo
from decopy.grouping.file_policy import splitting_path
from decopy.grouping.group_contracts import GroupProtocol
from decopy.grouping.grouping import GroupKey, make_group_key
from decopy.grouping.grouping_strategy import grouping_strategy_for
from decopy.licensing.holders import CopyrightHolders
from decopy.licensing.license_expr import (
    Node,
    parse_dep5_expression,
    render_dep5_expression,
    render_spdx_expression,
)

# The DEP-5 Files-paragraph fields the renderer emits from a group's own data;
# any other field a stored paragraph carried is passed through via extra_fields.
_STANDARD_FILES_FIELDS = frozenset({"Files", "Copyright", "License", "Comment"})


class TallyTracker:
    """Tracks nodes already emitted under a wildcard.

    When file paragraphs are wildcarded, a later, more specific group must not
    re-wildcard an ancestor an earlier group already covered. One tracker is
    shared across all groups in a single output run; it holds the bookkeeping
    that used to live on the nodes themselves.
    """

    def __init__(self) -> None:
        self._tallied: set[FileInfo] = set()

    def first_untallied(self, node: FileInfo) -> FileInfo:
        """The topmost ancestor of `node` (including itself) not yet tallied."""
        if node.parent is not None and node.parent not in self._tallied:
            return self.first_untallied(node.parent)
        return node

    def tally(self, node: FileInfo) -> None:
        """Mark `node` and all its ancestors as tallied."""
        current: FileInfo | None = node
        while current is not None and current not in self._tallied:
            self._tallied.add(current)
            current = current.parent


class FileGroup:
    def __init__(self) -> None:
        self.files: dict[str, FileInfo] = {}

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

    def __bool__(self) -> bool:
        return bool(self.files)

    def __iter__(self) -> abc.Iterator[FileInfo]:
        return iter(self.files.values())

    def __repr__(self) -> str:
        return f"FileGroup({self.sorted_members()!s})"

    def add_file(self, fileinfo: FileInfo) -> None:
        self.files[fileinfo.fullname] = fileinfo

    def del_file(self, fileinfo: FileInfo) -> FileInfo:
        return self.files.pop(fileinfo.fullname)

    def get_patterns(self, tracker: TallyTracker) -> list[str]:
        """Returns the wildcarded patters for the contained files.

        The order in which this method is called is important.  It should be
        called with the group that will get the more general wildcard first, and
        with the groups that will get more specific values later. The shared
        tracker carries that ordering state across groups.
        """
        patterns: set[str] = set()
        to_tally: set[FileInfo] = set()

        for fileinfo in self:
            untallied = tracker.first_untallied(fileinfo)
            to_tally.add(untallied)

        for fileinfo in to_tally:
            patterns.add(fileinfo.wildcard_pattern())
        for fileinfo in self:
            tracker.tally(fileinfo)

        return sorted(patterns)

    def path_key(self, options: Options) -> str:
        if not self.files:
            return ""
        return os.path.commonpath(
            tuple(t.cast(str, splitting_path(v, options)) for v in self.files.values()),
        )

    def commonpath(self) -> str:
        if not self.files:
            return ""
        return os.path.commonpath(tuple(f.fullname for f in self.files.values()))

    def sorted_members(self) -> list[str]:
        return sorted(self.files.keys())


class Group(GroupProtocol):
    """Group class for managing files and their associated copyright/license
    information
    """

    def __init__(
        self,
        key: GroupKey | None = None,
        position: int = 0,
    ) -> None:
        self.key: GroupKey | None = key
        self.files: FileGroup = FileGroup()
        self.copyrights: CopyrightHolders = CopyrightHolders()
        # The holders parsed from an existing paragraph's Copyright field,
        # snapshotted before any file holders are merged in. Explain mode
        # compares it against the kept files' holders to detect drift.
        self.declared_copyright_key: tuple[str, ...] = ()
        self.licenses: dict[str, License] = {}
        # The parsed licence expression when this group was read from an existing
        # paragraph, kept so the field can be re-emitted faithfully (and/or/comma
        # structure). None for groups decopy synthesises from a detected set.
        self.license_expr: Node | None = None
        self.license_filenames: set[str] = set()
        self.comments: list[str] = []
        self.stored: dc.FilesParagraph | None = None
        self.position: int = position

    @property
    def license(self) -> str:
        """The canonical license key for this group (used for grouping/matching).

        Connector-agnostic: the detected set joined with " or " and sorted, so a
        file and its existing paragraph produce the same key. The output License
        field is `license_field`, not this.
        """
        name = " or ".join(sorted(self.licenses))
        if not name:
            if self.copyrights:
                return UNKNOWN_COPYRIGHTED
            return UNKNOWN
        return name

    @property
    def license_field(self) -> str:
        """The License field text to render for this group.

        When the group was read from an existing paragraph, render from the
        preserved expression so and/or/comma structure round-trips; fall back to
        an SPDX expression when DEP-5 cannot represent the tree without changing
        its meaning. Synthesised groups (no expression) use `license`.
        """
        if self.license_expr is None:
            return self.license
        dep5 = render_dep5_expression(self.license_expr)
        if parse_dep5_expression(dep5) == self.license_expr:
            return dep5
        return render_spdx_expression(self.license_expr)

    def __str__(self) -> str:
        return str(self.key) if self.key else self.license

    def __repr__(self) -> str:
        return f"Group({self!s})"

    def add_file(self, fileinfo: FileInfo) -> None:
        """Add a file to this group and update associated metadata."""
        self.files.add_file(fileinfo)
        self.copyrights.merge(fileinfo.copyrights)
        for name in fileinfo.get_licenses():
            self.licenses[name] = License(name)
        self.license_filenames.update(fileinfo.get_license_filenames())

    def add_copyrights(self, copyrights: abc.Iterable[CopyrightHolder]) -> None:
        """Add copyright information to this group."""
        self.copyrights.extend(copyrights)

    def get_inherited_comment(self) -> str | None:
        """Get inherited comment for license files."""
        if not self.license_filenames:
            return None
        # A group read from an existing paragraph already carries its own
        # comment; don't synthesise the "No explicit license found" note on top
        # of it.
        if self.stored:
            return None
        filenames = sorted(self.license_filenames)
        return "No explicit license found, using license(s) from:\n {}".format(
            "\n ".join(filenames),
        )

    def get_comments(self) -> str:
        """Get all comments for this group."""
        comments: list[str] = []
        inherited_comment = self.get_inherited_comment()
        if inherited_comment:
            comments.append(inherited_comment)
        comments.extend(self.comments)
        return "\n .\n ".join(comments)

    def extra_fields(self) -> abc.Iterator[tuple[str, str]]:
        """Yield the stored paragraph's non-standard (name, value) fields.

        The standard DEP-5 Files-paragraph fields are rendered by the renderer
        from the group's own data; any other field an existing paragraph carried
        (e.g. an X-* field) is surfaced here, so the renderer depends on this
        accessor rather than on the concrete debian paragraph type.
        """
        if not self.stored:
            return
        # Group owns the concrete dc.FilesParagraph, so the paragraph-shaped
        # access lives here (the renderer sees only this accessor). The stubs
        # type __iter__ as Iterable[str], hence the ignore on the for-loop.
        for field in self.stored:  # type: ignore[attr-defined]  # ty: ignore[not-iterable]
            if field in _STANDARD_FILES_FIELDS:
                continue
            yield field, self.stored[field]

    def get_license_key(self) -> str:
        """Get the license key for this group."""
        return self.license

    def get_copyright_key(self) -> tuple[str, ...]:
        """Get the copyright key for this group."""
        return self.copyrights.key()

    def get_path_key(self, options: Options) -> str:
        """Get the path key for this group."""
        return self.files.path_key(options)

    def get_key(
        self,
        options: Options,
        *,
        ignore_copyright: bool = False,
    ) -> GroupKey:
        """The group's key under `options`.

        With `ignore_copyright` the copyright slot is blanked, so two groups that
        differ only by their holders compare equal (used when deciding whether a
        file is merely misplaced rather than relicensed).

        The full key is computed once and cached on `self.key`. `options` is
        invariant for a Group's lifetime (one Options object per run), so the
        cache never has to notice a changed `options`.
        """
        if not self.key:
            self.key = make_group_key(
                self.get_license_key(),
                self.get_copyright_key(),
                self.get_path_key(options),
                options,
            )
        if ignore_copyright:
            return self.key._replace(copyright=())
        return self.key

    def copyright_block_valid(self) -> bool:
        """Check if this group can generate a valid copyright block."""
        if not self.files:
            return False
        if len(self.files) > 1:
            return True
        return bool(next(f.fullname for f in self.files))

    def sort_key(
        self,
        options: Options,
    ) -> tuple[int, int, int, int, t.Any]:
        strategy = grouping_strategy_for(options.group_by)
        debian_group = strategy.sort_band(
            self.license, self.get_path_key(options), split_debian=options.split_debian
        )

        return (
            debian_group,
            -1 * self.position,
            -1 * len(self.files),
            -1 * len(self.copyrights),
            self.get_key(options, ignore_copyright=True),
        )
