#!/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.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 ._regex import rex
from .core_types import CopyrightHolder, Options
from .log import logger
from .nodes import DirInfo, FileInfo
from .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)

        for root, dirs, files in tqdm(
            os.walk(options.root, topdown=True),
            desc="Building tree",
            unit="dir",
            dynamic_ncols=True,
            disable=(options.debug or not options.progress),
        ):
            logger.debug("Listing %s", root)
            to_remove: list[str] = []
            for directory in dirs:
                if options.exclude_directory_re.search(directory):
                    logger.debug("Ignoring directory %s as requested", directory)
                    to_remove.append(directory)
            for d in to_remove:
                dirs.remove(d)
            root_path = Path(root)
            to_remove = []
            for filename in files:
                if options.exclude_file_re.search(filename):
                    logger.debug("Ignoring file %s as requested", filename)
                    to_remove.append(filename)
                elif (root_path / filename).is_symlink():
                    logger.debug("Ignoring symlink %s", filename)
                    # Skip symlinks
                    to_remove.append(filename)

                # elif os.path.getsize(root_path / filename) == 0:
                #     # Skip empty files
                #     logger.debug('Skipping empty file %s', filename)
                #     to_remove.append(filename)
            for f in to_remove:
                files.remove(f)

            # Remove the "./"
            base = root[len(tree.root) + 1 :]
            tree.add(base.split(os.path.sep) if base else [], dirs, files)

        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 .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 tag_included_files(self, options: Options) -> None:
        """Determine which files to include, from the specified cmd options."""

        if not options.files:
            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()
