#!/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 . import file_policy
from ._regex import rex
from .copyright_contracts import CopyrightProtocol
from .core_types import License, Options
from .group import Group
from .group_contracts import GroupProtocol
from .grouping import GroupKey
from .grouping_strategy import grouping_strategy_for
from .holders import get_copyright_holders
from .log import logger
from .nodes import FileInfo
from .tree import RootInfo

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


class Copyright(dc.Copyright, CopyrightProtocol):
    """This class extends the debian.copyright.Copyright class, adding some
    mangling of paragraphs and the capability of interacting with the filetree
    that is the basis of how decopy works."""

    groups: list[GroupProtocol] | None = None

    @staticmethod
    def build(filetree: RootInfo, options: Options) -> Copyright:
        """Creates an instance of Copyright and then populates the filetree."""

        filepath = Path(options.copyright_file)
        if not filepath.is_absolute():
            filepath = Path(filetree.root) / filepath

        if not filepath.exists():
            return Copyright()

        try:
            with filepath.open() as c:
                copyright_ = Copyright(c, strict=False)
        except (dc.NotMachineReadableError, ValueError):
            return Copyright()

        return copyright_

    def process(self, filetree: RootInfo) -> None:
        """Match files in the filetree to copyright information."""
        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
            # TODO(maxy): for diff mode
            if not matched_patterns:
                logger.info("No matching pattern in:\n%s", paragraph.dump())
                continue

            # Some patterns (but not all) failed to match.
            # Those specific patterns get removed from the paragraph.
            # TODO(maxy): for diff mode
            if len(matched_patterns) != len(paragraph.files):
                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)
            license_ = License.get(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:
                # TODO(maxy): this might be interesting to print in the diff mode
                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)

            # Get license keys from the synopsis
            license_keys = rex.split(
                " or ",
                para_license.synopsis,
                flags=rex.IGNORECASE,
            )
            for key in license_keys:
                group.licenses[key] = License.get(key)
            # Does this file paragraph includes the license text?
            # Can we reuse it?
            if len(license_keys) == 1 and para_license.text:
                license_ = License.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)

            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
    ) -> 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)
        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 partial mode, only included files are taken into account
                if options.mode == "partial" 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:
                    # TODO(maxy): for diff mode
                    group.files.del_file(fileinfo)
                else:
                    # Update parsed copyrights
                    group.copyrights.merge(fileinfo.copyrights)
                    assigned.add(fileinfo)

    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
