#!/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 building and processing (orchestration over decopy.fstree.nodes)."""

from __future__ import annotations

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

from decopy.common._regex import rex
from decopy.common.core_types import CopyrightHolder, Options
from decopy.common.log import logger
from decopy.fstree.ignore import IgnoreMatcher
from decopy.fstree.nodes import DirInfo, FileInfo
from decopy.fstree.parsers import ParseSpec, parse_from_spec

try:
    from tqdm import tqdm
except ImportError:

    def tqdm[T](iterable: T, *a: t.Any, **kw: t.Any) -> T:  # type: ignore[no-redef]  # noqa: ANN401
        _ = a, kw
        return iterable


_KNOWN_LICENSE_FILENAMES = (r"^COPYING", r"^COPYRIGHT$", r"^LICENSE")

# Below this many files, parse in-process: a ProcessPool's fork+pickle overhead
# would outweigh the gain (and keeps the test suite off subprocesses).
_PARALLEL_MIN = 200

# One parsed file's result: (index into the item list, copyrights, licenses).
type _ParseOutcome = tuple[int, list[CopyrightHolder], list[str]]


def _parse_one(options: Options, indexed_spec: tuple[int, ParseSpec]) -> _ParseOutcome:
    """Parse one (index, spec); empty result on any error.

    `options` is bound via functools.partial so it reaches worker processes
    without a module global, and a single malformed file (e.g. a `.py` raising
    tokenize.TokenError) is skipped rather than aborting the whole run.
    """
    index, spec = indexed_spec
    try:
        copyrights, licenses = parse_from_spec(spec, options)
    except Exception as exc:  # a bad file is skipped, not fatal
        logger.debug("Failed to parse %s: %s", spec, exc)
        return index, [], []
    return index, copyrights, list(licenses)


class RootInfo(DirInfo):
    def __init__(self, root: str) -> None:
        super().__init__()
        self.root: str = root

    @property
    def names(self) -> abc.Iterator[str]:
        for item in self:
            yield str(item)

    def __getitem__(self, key: str) -> RootInfo | DirInfo | FileInfo:
        if not key or key == ".":
            return self

        current: FileInfo = self
        for path in key.split(os.path.sep):
            child = current.get_child(path)
            if child is None:
                msg = f"{key} not found"
                raise IndexError(msg)
            current = child
        return current

    @staticmethod
    def build(options: Options) -> RootInfo:
        tree = RootInfo(root=options.root)
        matcher = IgnoreMatcher(
            use_gitignore=options.use_gitignore,
            files_excluded=options.files_excluded,
            root=options.root,
        )

        for walk_entry in tqdm(
            os.walk(options.root, topdown=True),
            desc="Building tree",
            unit="dir",
            dynamic_ncols=True,
            disable=(options.debug or not options.progress),
        ):
            tree._scan_dir(walk_entry, matcher, options)

        if options.expand_archives:
            # Local import: archives.py imports tree node types at module level,
            # so importing it here (not at module scope) breaks the cycle.
            from decopy.fstree.archives import expand_subtree  # noqa: PLC0415

            expand_subtree(tree, options.root, options, depth=0)
            tree.recompute_total()
        tree.tag_included_files(options)
        return tree

    def _scan_dir(
        self,
        walk_entry: tuple[str, list[str], list[str]],
        matcher: IgnoreMatcher,
        options: Options,
    ) -> None:
        """Prune one os.walk directory's children, then add the survivors.

        Pruning happens in place on the dirs list so os.walk (topdown) does not
        descend into a removed directory. The dir's own .gitignore is registered
        first, so it applies to the children pruned here.
        """
        root, dirs, files = walk_entry
        logger.debug("Listing %s", root)
        reldir = root[len(self.root) + 1 :]
        root_path = Path(root)
        self._register_gitignore(matcher, root_path, reldir)

        def relpath(name: str) -> str:
            return f"{reldir}/{name}" if reldir else name

        def keep_dir(name: str) -> bool:
            if options.exclude_directory_re.search(name):
                logger.debug("Ignoring directory %s as requested", name)
                return False
            if matcher.is_ignored(relpath(name), is_dir=True):
                logger.debug("Ignoring directory %s (ignore list)", name)
                return False
            return True

        def keep_file(name: str) -> bool:
            if options.exclude_file_re.search(name):
                logger.debug("Ignoring file %s as requested", name)
                return False
            if (root_path / name).is_symlink():
                logger.debug("Ignoring symlink %s", name)
                return False
            if matcher.is_ignored(relpath(name), is_dir=False):
                logger.debug("Ignoring file %s (ignore list)", name)
                return False
            return True

        dirs[:] = [name for name in dirs if keep_dir(name)]
        files[:] = [name for name in files if keep_file(name)]
        self.add(reldir.split(os.path.sep) if reldir else [], dirs, files)

    def _register_gitignore(
        self, matcher: IgnoreMatcher, root_path: Path, reldir: str
    ) -> None:
        """Register this directory's .gitignore, anchored at reldir, if present.

        Reads from disk (not the pruned file list) since .gitignore is itself
        excluded by name yet must still inform pruning.
        """
        if not matcher.use_gitignore:
            return
        gitignore = root_path / ".gitignore"
        if gitignore.is_file():
            matcher.add_gitignore(
                reldir, gitignore.read_text(encoding="utf-8").splitlines()
            )

    def tag_included_files(self, options: Options) -> None:
        """Determine which files to include, from the specified cmd options."""

        if not options.files and not options.scoped_to_subset:
            # A scoped run (partial/changed) with no files is an empty scope,
            # not the whole tree; only an unscoped run defaults to everything.
            self.included = True

        for filename in options.files:
            # Special case, if the root directory was received as a positional
            # parameter, include the whole tree.
            if filename == ".":
                self.included = True
                return

            # Filenames not in the tree will cause an exception. Catch that and
            # log an error in that case.
            try:
                self[filename].included = True
            except IndexError:
                logger.error("%s not in source tree", filename)

    def process(self, options: Options) -> None:
        """Parse every included file's licenses/copyrights, then aggregate
        directory licenses.

        Big trees parse in a process pool: the per-file work is GIL-bound (regex
        subs, holder parsing), so threads do not help; processes scale. Workers
        return results and the parent applies them - they cannot mutate the shared
        tree.
        """
        items = [
            item
            for item in self.walk_files()
            if item.included
            and item.parent is not None
            and not item.is_excluded(options)
        ]
        self._apply_results(items, self._parse_items(items, options))

    def _parse_items(
        self, items: list[FileInfo], options: Options
    ) -> list[_ParseOutcome]:
        """Parse every item, in a process pool for big trees (the per-file work is
        GIL-bound), serially otherwise. Same worker either way."""
        specs = [(i, item.parse_spec(self.root)) for i, item in enumerate(items)]
        worker = functools.partial(_parse_one, options)
        workers = options.jobs or (os.cpu_count() or 1)

        def progress(iterable: abc.Iterable[t.Any]) -> abc.Iterable[t.Any]:
            return tqdm(
                iterable,
                desc="Processing",
                total=len(specs),
                unit="file",
                dynamic_ncols=True,
                disable=(options.debug or not options.progress),
            )

        if workers > 1 and len(specs) >= _PARALLEL_MIN:
            chunk = max(1, len(specs) // (workers * 4))
            with futures.ProcessPoolExecutor(max_workers=workers) as executor:
                return list(progress(executor.map(worker, specs, chunksize=chunk)))
        return [worker(spec) for spec in progress(specs)]

    def _apply_results(
        self, items: list[FileInfo], results: list[_ParseOutcome]
    ) -> None:
        dirs_with_licenses: set[DirInfo] = set()
        for index, copyrights, licenses in results:
            item = items[index]
            item.add_copyrights(copyrights)
            item.add_licenses(licenses)
            parent = item.parent
            if parent is None:
                continue
            for filename_re in _KNOWN_LICENSE_FILENAMES:
                if rex.match(filename_re, item.name):
                    resolved: futures.Future[FileInfo | None] = futures.Future()
                    resolved.set_result(item)
                    parent.tasks.add(resolved)
                    dirs_with_licenses.add(parent)
                    break
        for dirinfo in dirs_with_licenses:
            dirinfo.collect_child_licenses()
