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

from __future__ import annotations

import dataclasses

from decopy.cmdoptions import process_options
from decopy.common.core_types import Options
from decopy.common.log import logger
from decopy.dep5doc.copyright_processor import resolve_copyright_path
from decopy.dep5doc.copyright_reader import read_copyright, read_files_excluded
from decopy.dep5doc.delta import (
    NEW_PARAGRAPH,
    ChangedDelta,
    DeltaEntry,
    DeltaReport,
    NullDelta,
    PartialDelta,
    freeze_holder_sources,
)
from decopy.dep5doc.dep5 import Copyright, Group
from decopy.dep5doc.output import generate_explain, generate_output
from decopy.dep5doc.renderer import renderer_for
from decopy.fstree import vcs
from decopy.fstree.nodes import FileInfo
from decopy.fstree.tree import RootInfo
from decopy.grouping.file_policy import group_key, holder_sources, license_source
from decopy.grouping.group_contracts import GroupProtocol
from decopy.grouping.grouping import GroupKey


def _assigned_files(copyright_: Copyright) -> set[FileInfo]:
    """Files still held by an existing group after remove_misplaced_files.

    Those files matched their existing paragraph, so the assembly loop must not
    place them in a fresh group a second time.
    """
    return {fileinfo for group in copyright_.groups or () for fileinfo in group.files}


def prepare_output_groups(
    filetree: RootInfo,
    copyright_: Copyright,
    options: Options,
) -> dict[GroupKey, GroupProtocol]:
    assigned: set[FileInfo] = _assigned_files(copyright_)
    groups: dict[GroupKey, GroupProtocol] = copyright_.get_group_dict(options)

    for fileinfo in filetree.walk_files():
        if options.scoped_to_subset and not fileinfo.included:
            continue

        # if already assigned, the file matched its copyright group
        if fileinfo in assigned:
            continue

        # The file is not covered by any kept group, so it gets a fresh one.
        file_key = group_key(fileinfo, options)
        group = groups.setdefault(file_key, Group(file_key))
        group.add_file(fileinfo)
        assigned.add(fileinfo)

    return groups


def _copyright_relpath(options: Options) -> str:
    """The copyright file path relative to the tree root, matching fullname.

    Uses the shared resolver so the in-tree debian/copyright is identified the
    same way the rest of the pipeline refers to it. Keyed off options.root
    (== RootInfo.root) so it can run before the tree is built.
    """
    filepath = resolve_copyright_path(options)
    try:
        return str(filepath.relative_to(options.root))
    except ValueError:
        return str(filepath)


def _resolve_run_options(options: Options) -> tuple[Options, tuple[str, ...]]:
    """Settle the run's scope into a fresh Options plus the deleted-paths set.

    Loads the header Files-Excluded patterns and, in changed mode, unions the
    git-changed set into the file scope. Returns a *new* Options (the input is
    never mutated, so every downstream consumer reads an invariant) and the paths
    removed since the baseline (deletions plus the old side of renames), which
    name the deletions that may have emptied a paragraph (stale-paragraph
    provenance). On any git unavailability the run degrades to a full scan (mode
    reset to "full") after one warning, with () deleted.
    """
    files_excluded = read_files_excluded(options)
    if options.mode != "changed":
        return dataclasses.replace(options, files_excluded=files_excluded), ()

    scope = vcs.changed_paths(options.root, _copyright_relpath(options), options.since)
    if isinstance(scope, vcs.Fallback):
        logger.warning("%s", scope.message)
        return (
            dataclasses.replace(options, mode="full", files_excluded=files_excluded),
            (),
        )

    renamed_new = {new for _, new in scope.renamed}
    files = sorted(set(options.files) | set(scope.added_or_modified) | renamed_new)
    deleted = tuple(sorted(set(scope.deleted) | {old for old, _ in scope.renamed}))
    resolved = dataclasses.replace(options, files=files, files_excluded=files_excluded)
    return resolved, deleted


def _skip_new_file(
    fileinfo: FileInfo, options: Options, copyright_relpath: str
) -> bool:
    """A file does not seed a new paragraph when partial mode excludes it or it
    is the in-tree copyright file itself (its presence is not a staleness
    signal). Whether it is already filed is decided by the assigned set in
    _new_paragraph_buckets, so a relicensed file (removed from its group) is no
    longer hidden here."""
    if options.scoped_to_subset and not fileinfo.included:
        return True
    return fileinfo.fullname == copyright_relpath


def _new_paragraph_buckets(
    filetree: RootInfo, copyright_: Copyright, options: Options
) -> dict[GroupKey, list[FileInfo]]:
    """Bucket the files regeneration would (re)group into fresh paragraphs.

    A file seeds a new paragraph when it is not kept in an existing paragraph
    (uncovered, or relicensed and removed from its group) and its destination
    key is not an existing paragraph's key (else it joins that paragraph). Both
    tests are needed: the kept set catches a file a no-copyright fallback
    rescued; the keys catch a move into a paragraph that already declares the
    destination license.
    """
    assigned = _assigned_files(copyright_)
    existing = {group.get_key(options) for group in copyright_.groups or ()}
    copyright_relpath = _copyright_relpath(options)
    buckets: dict[GroupKey, list[FileInfo]] = {}
    for fileinfo in filetree.walk_files():
        if fileinfo in assigned or _skip_new_file(fileinfo, options, copyright_relpath):
            continue
        key = group_key(fileinfo, options)
        if key in existing:
            continue
        buckets.setdefault(key, []).append(fileinfo)
    return buckets


def _record_new_paragraphs(
    buckets: dict[GroupKey, list[FileInfo]], delta: DeltaReport
) -> None:
    """Record each bucket of (re)grouped files as the paragraph regeneration
    would add: its files, the license and where it was found, and each holder's
    source files. The NullDelta sink absorbs the call on generation runs.
    """
    for key, bucket in buckets.items():
        files = sorted(bucket, key=lambda fileinfo: fileinfo.fullname)
        delta.add(
            DeltaEntry(
                kind=NEW_PARAGRAPH,
                detail=key.license,
                now=key.license,
                files=tuple(fileinfo.fullname for fileinfo in files),
                license_source=license_source(files[0]),
                holder_sources=freeze_holder_sources(holder_sources(files)),
            )
        )


def _delta_sink(options: Options) -> DeltaReport:
    """Pick the recording sink for this run.

    Generation runs get the no-op NullDelta (byte-for-byte unchanged). Explain
    runs collect into a DeltaReport, a ChangedDelta (keeps the whole-tree-sound
    stale-* but drops drift) in changed mode, or a PartialDelta that also drops
    stale-* when scoped to a hand-named subset. The changed branch is the one
    place changed mode diverges from partial.
    """
    if not options.explain:
        return NullDelta()
    if options.mode == "changed":
        return ChangedDelta()
    if options.scoped_to_subset:
        return PartialDelta()
    return DeltaReport()


def run_pipeline(options: Options) -> int:
    """Run the full options -> tree -> dep5 -> output flow for `options`.

    Shared by main() and the benchmark/golden-output tooling so they all
    exercise the exact same code path. Returns the process exit code: 0 for a
    normal run, and in explain mode 1 when the report has entries else 0.
    """
    # Resolve the git-changed scope first, into a fresh (still immutable)
    # Options: on a fallback it resets the mode to "full", which the sink
    # selection below must see so a degraded run gets a full DeltaReport rather
    # than a PartialDelta. Rebinding `options` here means every step below reads
    # the settled scope.
    options, deleted = _resolve_run_options(options)

    report: DeltaReport = _delta_sink(options)
    filetree: RootInfo = RootInfo.build(options)
    copyright_ = read_copyright(options)

    copyright_.process(filetree, report, deleted)
    filetree.process(options)

    copyright_.remove_misplaced_files(options, None, report)
    groups = prepare_output_groups(filetree, copyright_, options)

    renderer = renderer_for(options)
    if options.explain:
        # New-paragraph recording is explain-only; skipping it on generation
        # runs avoids a second full tree walk (and group_key recompute) whose
        # deltas the NullDelta sink would only discard.
        buckets = _new_paragraph_buckets(filetree, copyright_, options)
        _record_new_paragraphs(buckets, report)
        generate_explain(report, options)
        # The report owns stdout; with -o (a real file, not the stdout
        # sentinel) also regenerate the fresh copyright to that file. The
        # in-tree debian/copyright is never touched.
        if options.output:
            generate_output(groups, filetree, copyright_, options, renderer)
        return 1 if report.has_staleness() else 0

    generate_output(groups, filetree, copyright_, options, renderer)
    return 0


def main() -> int:
    options: Options = process_options()
    return run_pipeline(options)
