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

"""Copyright processing and management"""

from __future__ import annotations

import fnmatch
import typing as t
from pathlib import Path

import debian.copyright as dc

from decopy.common.core_types import LicenseRegistry, Options
from decopy.common.log import logger
from decopy.dep5doc import staleness
from decopy.dep5doc.copyright_contracts import CopyrightProtocol
from decopy.dep5doc.delta import DeltaReport, NullDelta
from decopy.fstree.nodes import FileInfo
from decopy.fstree.tree import RootInfo
from decopy.grouping import file_policy
from decopy.grouping.group import Group
from decopy.grouping.group_contracts import GroupProtocol
from decopy.grouping.grouping import GroupKey
from decopy.grouping.grouping_strategy import grouping_strategy_for
from decopy.licensing.holders import get_copyright_holders
from decopy.licensing.license_expr import Node, license_atoms
from decopy.licensing.spdx import canonical_license_name, parse_license_synopsis

type FilesGroups = list[tuple[dc.FilesParagraph, set[str]]]

# Shared no-op sink used as the default when no real report is threaded in.
# NullDelta.add() stores nothing, so a single instance is safe to share and
# lets the recording sites call delta.add() without a None guard.
_NULL_DELTA = NullDelta()


def resolve_copyright_path(options: Options) -> Path:
    """Absolute path to the copyright file, honouring an absolute setting.

    Relative paths are resolved against ``options.root`` (== ``RootInfo.root``),
    so it can run before the tree is built. The single source of truth for where
    the in-tree debian/copyright lives, shared by every consumer that reads or
    refers to it.
    """
    filepath = Path(options.copyright_file)
    if filepath.is_absolute():
        return filepath
    return Path(options.root) / filepath


def _parse_synopsis(synopsis: str) -> tuple[list[str], Node | None]:
    """Parse a Files-paragraph License synopsis into (atom keys, expression).

    Returns the licence atoms to key the group by and the parsed expression to
    keep for re-emission. An empty or malformed synopsis (which DEP-5 cannot
    represent) degrades to a single literal key and no expression.
    """
    try:
        expr = parse_license_synopsis(synopsis)
    except ValueError:
        return [synopsis], None
    return license_atoms(expr), expr


class Copyright(CopyrightProtocol):
    """Matches a filetree against an existing debian/copyright document.

    Wraps a debian.copyright.Copyright by *composition* (not inheritance): the
    parsed document is the boundary to python-debian, and only the pieces decopy
    needs -- the header and the Files/License paragraphs -- are exposed by
    delegation. Everything else here (process/remove_misplaced_files/group
    assembly) is decopy's own. Reading a document off disk lives in
    copyright_reader; an empty document is the default.
    """

    groups: list[GroupProtocol] | None = None

    def __init__(
        self,
        document: dc.Copyright | None = None,
        registry: LicenseRegistry | None = None,
    ) -> None:
        self._document = document if document is not None else dc.Copyright()
        self.registry = registry if registry is not None else LicenseRegistry()

    @property
    def header(self) -> dc.Header:
        """The wrapped document's header (a dc.Header)."""
        return self._document.header

    def all_files_paragraphs(self) -> t.Iterator[dc.FilesParagraph]:
        """The wrapped document's Files paragraphs."""
        return self._document.all_files_paragraphs()

    def all_license_paragraphs(self) -> t.Iterator[dc.LicenseParagraph]:
        """The wrapped document's standalone License paragraphs."""
        return self._document.all_license_paragraphs()

    def process(
        self,
        filetree: RootInfo,
        delta: DeltaReport = _NULL_DELTA,
        deleted: tuple[str, ...] = (),
    ) -> None:
        """Match files in the filetree to copyright information.

        `deleted` (changed mode) names paths removed since the baseline, used to
        attribute a now-stale paragraph to the deletion that emptied it.
        """
        self._populate_license_singletons()

        files_paragraphs: FilesGroups = []

        # Scan the files paragraphs in reverse order, that way the files should
        # match only once.
        paragraphs = list(self.all_files_paragraphs())
        for paragraph in reversed(paragraphs):
            # apply fnmatch to each file_pattern
            matched_patterns, files = self._get_matched_patterns(paragraph, filetree)

            # Nothing in the paragraph matched any files. The paragraph will not
            # be included in files_paragraphs.
            if not matched_patterns:
                logger.info("No matching pattern in:\n%s", paragraph.dump())
                staleness.record_stale_paragraph(delta, paragraph, deleted)
                continue

            # Some patterns (but not all) failed to match.
            # Those specific patterns get removed from the paragraph; the dead
            # ones are recorded here, where both declared and matched sets are
            # in scope (a paragraph that matched nothing is recorded above as a
            # single stale paragraph, never as N stale patterns).
            if len(matched_patterns) != len(paragraph.files):
                staleness.record_stale_patterns(delta, paragraph, matched_patterns)
                paragraph.files = matched_patterns  # type: ignore[assignment]  # ty: ignore[invalid-assignment]

            files_paragraphs.append((paragraph, files))

        self._process_groups(filetree, files_paragraphs)

    def _populate_license_singletons(self) -> None:
        """Store each standalone license paragraph in the matching License singleton."""
        for license_para in self.all_license_paragraphs():
            paragraph_license = t.cast(dc.License, license_para.license)
            # A standalone stanza header is a single licence name, so canonicalise
            # it directly (no expression parse) to the same key the Files-paragraph
            # groups use; otherwise the stored text is keyed by a spelling no group
            # looks up and is dropped from the output.
            license_ = self.registry.get(
                canonical_license_name(paragraph_license.synopsis)
            )
            license_.stored = license_para

    @staticmethod
    def _get_matched_patterns(
        paragraph: dc.FilesParagraph,
        filetree: RootInfo,
    ) -> tuple[list[str], set[str]]:
        """Returns the files that match the patterns for a paragraph."""
        matched_patterns: list[str] = []
        files: set[str] = set()

        for file_pattern in paragraph.files:
            found = False
            for filename in fnmatch.filter(filetree.names, file_pattern):
                fileinfo = filetree[filename]

                # If the file already has a matching pattern, it was already
                # matched by previous paragraphs.
                if fileinfo.matching_pattern:
                    continue

                # Set the matched pattern
                fileinfo.matching_pattern = file_pattern
                if not fileinfo.is_container:
                    files.add(filename)

                if not found:
                    matched_patterns.append(file_pattern)
                    found = True

            if not found:
                logger.info("No match found for %s", file_pattern)

        return matched_patterns, files

    def _process_groups(
        self,
        filetree: RootInfo,
        files_paragraphs: FilesGroups,
    ) -> None:
        """Process files paragraphs and create groups."""
        self.groups = []

        for position, (paragraph, files) in enumerate(files_paragraphs):
            group = Group(position=position)
            group.stored = paragraph

            para_license = t.cast(dc.License, paragraph.license)
            para_copyright = t.cast(str, paragraph.copyright)
            para_comment = t.cast(str, paragraph.comment)

            # Parse the synopsis (DEP-5 sec 7.2: and/or/comma) into an
            # expression, keep it for faithful re-emission, and key the group's
            # licences by its atoms regardless of the and/or shape. A malformed
            # or empty synopsis degrades to a single literal key.
            license_keys, group.license_expr = _parse_synopsis(para_license.synopsis)
            for key in license_keys:
                group.licenses[key] = self.registry.get(key)
            # Does this file paragraph includes the license text?
            # Can we reuse it?
            if len(license_keys) == 1 and para_license.text:
                license_ = self.registry.get(license_keys[0])
                if not license_.stored:
                    license_.stored = dc.LicenseParagraph.create(para_license)

            holders = []
            if para_copyright:
                for value in para_copyright.split("\n"):
                    value_ = value.lstrip()
                    new_holders, _ = get_copyright_holders(value_)
                    holders.extend(new_holders)
            group.add_copyrights(holders)
            # Snapshot the declared holders before the file loop (and the later
            # remove_misplaced_files merge) can fold in any file holders.
            group.declared_copyright_key = group.copyrights.key()

            for filename in files:
                fileinfo = filetree[filename]
                fileinfo.existing_group = group
                group.add_file(fileinfo)

            if para_comment:
                group.comments.append(para_comment)

            self.groups.append(group)

    def remove_misplaced_files(
        self,
        options: Options,
        assigned: set[FileInfo] | None = None,
        delta: DeltaReport = _NULL_DELTA,
    ) -> None:
        """Remove files from groups where they no longer belong.

        This method should be called after files have been processed and their
        licensing and copyright information has been obtained. Files that do not
        match their placement in the copyright file get removed from those
        groups. No files are added to existing or new groups.

        Files that keep their placement are added to `assigned` (a caller-owned
        set), so a later assembly pass knows they are already grouped. When the
        caller does not need that, `assigned` is left None and tracked locally.
        """
        if assigned is None:
            assigned = set()
        strategy = grouping_strategy_for(options.group_by)
        for group in self.groups or ():
            group_key = group.get_key(options)
            group_key_no_copyright = group.get_key(options, ignore_copyright=True)
            logger.debug("Keys: %s %s", group_key, group_key_no_copyright)

            # Make an explicit copy to be able to remove items from the list.
            for fileinfo in list(group.files):
                # In a scoped run, only included files are taken into account
                if options.scoped_to_subset and not fileinfo.included:
                    group.files.del_file(fileinfo)
                    continue

                # Check if the current key for the file matches the group key.
                file_key = file_policy.group_key(fileinfo, options)
                logger.debug("File: %s %s", fileinfo, file_key)
                remove = not strategy.keeps_misplaced(
                    file_key, group_key, group_key_no_copyright
                )

                # Either delete from the group or record the file as assigned,
                # to show that the file has matched its group.
                if remove:
                    staleness.record_relicensed(delta, fileinfo, (file_key, group_key))
                    group.files.del_file(fileinfo)
                else:
                    # Update parsed copyrights
                    group.copyrights.merge(fileinfo.copyrights)
                    assigned.add(fileinfo)

            # The kept files are settled; report any drift between them and the
            # paragraph's declared Copyright field.
            staleness.record_copyright_drift(delta, group, strategy)

    def get_group_dict(self, options: Options) -> dict[GroupKey, GroupProtocol]:
        """Return a dictionary of groups, keyed by their key."""
        groups: dict[GroupKey, GroupProtocol] = {}

        for group in self.groups or ():
            key = group.get_key(options)
            if key in groups:
                # If the group already exists, add the files to it
                for fileinfo in group.files:
                    groups[key].add_file(fileinfo)
            else:
                groups[key] = group
        return groups
