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

"""Filesystem tree node types: the data structures decopy builds and reads.

FileInfo/DirInfo (and the ArchiveDirInfo/VirtualFileInfo variants) hold the
per-file licensing and copyright data plus the tree shape. The orchestration
that builds and processes the tree lives in tree.py; the policy that derives a
group identity from a node lives in file_policy.py.
"""

from __future__ import annotations

import collections.abc as abc
import typing as t
from concurrent import futures
from pathlib import Path

from decopy.common.core_types import CopyrightHolder, Options
from decopy.common.log import logger
from decopy.fstree.parsers import ParseSpec, parse_from_spec
from decopy.licensing.holders import CopyrightHolders

if t.TYPE_CHECKING:
    from decopy.grouping.group_contracts import LicenseKeyed


class FileInfo:
    def __init__(self, parent: DirInfo | None, name: str) -> None:
        self.parent: DirInfo | None = parent
        self.name: str = name
        self.licenses: set[str] = set()
        self.license_filenames: set[str] = set()
        # Group from the existing debian/copyright file. Only its license key is
        # read (in file_policy.license_key), so it depends on LicenseKeyed
        # rather than the full GroupProtocol.
        self.existing_group: LicenseKeyed | None = None
        # The pattern in the debian/copyright that matches this file
        self.matching_pattern: str | None = None
        self.copyrights: CopyrightHolders = CopyrightHolders()
        # Store whether file was included in the files/dirs to process
        self._included: bool | None = None

    def __str__(self) -> str:
        return self.fullname

    @property
    def fullname(self) -> str:
        # Plain string join, not pathlib: this is hot (called once per node per
        # grouping/matching/render pass) and Path() construction dominated the
        # profile. Equivalent to str(Path(parent) / name) for single-component
        # names, including the empty-parent (root child) case.
        if self.parent:
            parent_full = self.parent.fullname
            return f"{parent_full}/{self.name}" if parent_full else self.name
        return self.name

    @property
    def parsed_license(self) -> str:
        return " or ".join(sorted(self.licenses))

    @property
    def effective_license(self) -> str:
        """The license this node contributes when resolving a file's license.

        For a file it is the raw parsed expression; DirInfo overrides this to
        mark the license as inherited by the directory's contents.
        """
        return self.parsed_license

    def add_copyrights(self, copyrights: abc.Iterable[CopyrightHolder]) -> None:
        self.copyrights.extend(copyrights)

    def add_licenses(
        self,
        licenses: abc.Iterable[str],
        license_filenames: abc.Iterable[str] | None = None,
    ) -> None:
        """Add one or more detected licenses for this file.

        Args:
            licenses: a set of license keys.
            license_filenames: for licenses stored in separate files, the filenames
                hold the license information.
        """
        self.licenses.update(licenses)
        if license_filenames:
            self.license_filenames.update(license_filenames)

    def get_licenses(self) -> set[str]:
        if self.licenses or not self.parent:
            return self.licenses
        return self.parent.get_licenses()

    def get_license_filenames(self) -> set[str]:
        if self.licenses or not self.parent:
            return self.license_filenames
        return self.parent.get_license_filenames()

    @property
    def included(self) -> bool:
        if self._included is not None:
            return self._included
        if self.parent:
            self._included = self.parent.included
            return self._included
        return False

    @included.setter
    def included(self, value: bool | None) -> None:
        self._included = value

    def is_excluded(self, options: Options) -> bool:
        return bool(
            options.exclude_fullname_re.match(self.fullname)
            or options.exclude_special_re.match(self.fullname)
        )

    def is_split_boundary(self, options: Options) -> bool:
        """Whether this node starts a new splitting group.

        A file never does; it delegates to its containing directory. DirInfo
        overrides this.
        """
        _ = options
        return False

    def is_packaging(self) -> bool:
        """Whether this node is the debian/ packaging dir or lives under it.

        debian/copyright is the authoritative record for packaging files, so
        explain must not derive drift/relicense signals from scanning them. Keys
        off fullname, so one base method covers both files and directories.
        """
        return self.fullname == "debian" or self.fullname.startswith("debian/")

    def wildcard_pattern(self) -> str:
        """The DEP-5 Files pattern that stands for this node.

        A file is named literally; DirInfo overrides this to wildcard its
        contents.
        """
        return self.fullname

    def as_container(self) -> DirInfo | None:
        """This node viewed as a directory, or None if it is not one.

        Lets callers obtain the container without testing the node's type; a
        file is never a container, DirInfo returns itself.
        """
        return None

    @property
    def is_container(self) -> bool:
        """Whether this node holds other nodes (i.e. is a directory)."""
        return self.as_container() is not None

    @property
    def children(self) -> abc.Iterable[FileInfo]:
        """The node's direct children. A file has none; DirInfo overrides."""
        return ()

    def get_child(self, name: str) -> FileInfo | None:
        """The direct child named `name`, or None. A file has no children;
        DirInfo overrides."""
        _ = name
        return None

    def walk(self) -> abc.Iterator[FileInfo]:
        """Yield this node, then every descendant (pre-order)."""
        yield self
        for child in self.children:
            yield from child.walk()

    def walk_files(self) -> abc.Iterator[FileInfo]:
        """Yield every leaf (non-container) node in this subtree, pre-order."""
        for node in self.walk():
            if not node.is_container:
                yield node

    def recompute_total(self) -> int:
        """Number of leaf files in this subtree. A file is one; DirInfo sums
        its children."""
        return 1

    def parse_spec(self, root: str) -> ParseSpec:
        """A picklable description of how to parse this node (for worker procs)."""
        return ("file", str(Path(root) / self.fullname), b"")

    def process_licenses(self, root: str, options: Options) -> FileInfo | None:
        """Parse the license and copyright information for this file."""
        if self.is_excluded(options):
            logger.debug("Ignored file %s (by --exclude)", self.name)
            return None
        copyrights, licenses = parse_from_spec(self.parse_spec(root), options)
        logger.debug("Adding copyrights %s, to %s", copyrights, self)
        self.add_copyrights(copyrights)
        logger.debug("Adding licenses %s, to %s", licenses, self)
        self.add_licenses(licenses)
        return self


class DirInfo(FileInfo):
    def __init__(self, parent: DirInfo | None = None, name: str = "") -> None:
        super().__init__(parent, name)
        self.file_list: dict[str, FileInfo | DirInfo] = {}
        # Total number of files in sub tree
        self.total: int = 0
        self.tasks: set[futures.Future[FileInfo | None]] = set()

    @property
    def effective_license(self) -> str:
        if self.licenses:
            return "Inherited({})".format(" or ".join(sorted(self.licenses)))
        return ""

    def is_split_boundary(self, options: Options) -> bool:
        """A directory starts a new splitting group at the tree root, where it
        holds a license under --split-on-license, or for "debian" under
        --split-debian.
        """
        return bool(
            (options.split_on_license and self.licenses)
            or not self.parent
            or (options.split_debian and self.fullname == "debian")
        )

    def wildcard_pattern(self) -> str:
        """A directory stands for everything under it: "dir/*" (or "*" at the
        tree root, where fullname is empty)."""
        if self.fullname:
            return self.fullname + "/*"
        return "*"

    def add(
        self,
        path: abc.Sequence[str],
        dirs: abc.Sequence[str],
        files: abc.Sequence[str],
    ) -> None:
        # Update the total number of files
        self.total += len(dirs) + len(files)

        if path:
            sub = self.get_or_create_subdir(path[0])
            # sub is None only when a file already occupies the name, which does
            # not happen on a real filesystem walk; skip rather than descend.
            if sub is not None:
                sub.add(path[1:], dirs, files)
            return

        for name in dirs:
            if name not in self.file_list:
                self.file_list[name] = DirInfo(self, name)
        for name in files:
            if name not in self.file_list:
                self.file_list[name] = FileInfo(self, name)

    def as_container(self) -> DirInfo | None:
        return self

    @property
    def children(self) -> abc.Iterable[FileInfo]:
        return self.file_list.values()

    def get_child(self, name: str) -> FileInfo | None:
        return self.file_list.get(name)

    def get_subdir(self, name: str) -> DirInfo | None:
        """The child named `name` if it is a subdirectory, else None."""
        child = self.file_list.get(name)
        return child.as_container() if child is not None else None

    def get_or_create_subdir(self, name: str) -> DirInfo | None:
        """The subdirectory named `name`, creating it if absent.

        Returns None if `name` is already taken by a file.
        """
        child = self.file_list.get(name)
        if child is None:
            sub = DirInfo(self, name)
            self.file_list[name] = sub
            return sub
        return child.as_container()

    def __iter__(self) -> abc.Iterator[FileInfo]:
        return self.walk()

    def collect_child_licenses(self) -> DirInfo:
        """Aggregate the licenses found in this directory's license-file children.

        Drains the futures queued in self.tasks (populated by RootInfo.process),
        recording the licenses and filenames of any child that parsed as a
        license file.

        DirInfo inherits FileInfo.process_licenses, which parses a single file,
        but never runs it: RootInfo.process only submits leaf files for parsing.
        """
        dir_licenses: set[str] = set()
        filenames: set[str] = set()

        for task in futures.as_completed(self.tasks):
            fileinfo = task.result()
            if not fileinfo:
                continue
            licenses = fileinfo.licenses
            if not licenses:
                # Not a license?
                continue

            dir_licenses.update(licenses)
            filenames.add(fileinfo.fullname)

        self.tasks = set()

        if dir_licenses:
            self.add_licenses(dir_licenses, filenames)

        return self

    def recompute_total(self) -> int:
        self.total = sum(child.recompute_total() for child in self.children)
        return self.total


class ArchiveDirInfo(DirInfo):
    def __init__(self, parent: DirInfo, name: str, archive_path: str) -> None:
        super().__init__(parent, name)
        self.archive_path: str = archive_path


class VirtualFileInfo(FileInfo):
    def __init__(self, parent: DirInfo, name: str, content: bytes) -> None:
        super().__init__(parent, name)
        self._content: bytes = content

    def parse_spec(self, root: str) -> ParseSpec:
        _ = root
        return ("bytes", self.name, self._content)
