#!/bin/bash
# copyright-audit — Debian copyright file generator, checker, and reviewer
#
# Copyright (C) 2026 Erich Eickmeyer
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
#
# Uses ScanCode Toolkit (SPDX license database) when available for high-
# accuracy detection, falling back to licensecheck otherwise. Can generate
# a new debian/copyright, check an existing one, or produce a reviewer report.
#
# Usage:
#   copyright-audit [mode] [options] [source-dir]
#
# Modes:
#   check      Validate existing debian/copyright (default)
#   generate   Scan source tree and produce a new debian/copyright on stdout
#   review     Reviewer mode: structured pass/fail report for archive review
#
# Options:
#   -e, --exclude PATTERN   Glob pattern to exclude from scan (repeatable)
#   -o, --output FILE       Write generated copyright to FILE (generate mode)
#   -f, --fix               Interactively fix issues found (check mode only)
#   --yes                   Auto-accept all fixes (use with --fix, no prompts)
#   -v, --verbose           Show all mismatches including likely false positives
#   --export FILE          Export full review findings to FILE (review mode)
#   --no-fetch              Don't download license text from SPDX/CC (use FIXME stubs)
#   -j, --jobs N            Parallel scancode workers (default: nproc)
#   --cache-dir DIR         Reuse scan results from DIR (skip scanning if present)
#   -h, --help              Show this help
#
# Scanner preference (automatic):
#   1. scancode  — SPDX database, highest accuracy (pip install scancode-toolkit)
#   2. licensecheck + decopy — Debian-native tools (apt install licensecheck
#      decopy)
#
# If source-dir is omitted, the current directory is used.
# Requires: python3
# At least one of: scancode-toolkit (pip), licensecheck (apt)

set -euo pipefail

MODE="check"
EXCLUDE_PATTERNS=()
OUTPUT=""
EXPORT=""
VERBOSE=false
FIX_MODE=false
YES_MODE=false
NO_FETCH=false
JOBS=""
CACHE_DIR=""
SRCDIR=""

show_help() {
    sed -n '2,/^$/{ s/^# //; s/^#$//; p }' "$0"
    exit 0
}

while [[ $# -gt 0 ]]; do
    case "$1" in
        check|generate|review)
            MODE="$1"; shift ;;
        -e|--exclude)
            [[ $# -ge 2 ]] || { echo "Error: $1 requires an argument" >&2; exit 1; }
            EXCLUDE_PATTERNS+=("$2"); shift 2 ;;
        -o|--output)
            [[ $# -ge 2 ]] || { echo "Error: $1 requires an argument" >&2; exit 1; }
            OUTPUT="$2"; shift 2 ;;
        -f|--fix)
            FIX_MODE=true; shift ;;
        --yes)
            YES_MODE=true; shift ;;
        --export)
            [[ $# -ge 2 ]] || { echo "Error: $1 requires an argument" >&2; exit 1; }
            EXPORT="$2"; shift 2 ;;
        -v|--verbose)
            VERBOSE=true; shift ;;
        --no-fetch)
            NO_FETCH=true; shift ;;
        --cache-dir)
            [[ $# -ge 2 ]] || { echo "Error: $1 requires an argument" >&2; exit 1; }
            CACHE_DIR="$2"; shift 2 ;;
        -j|--jobs)
            [[ $# -ge 2 ]] || { echo "Error: $1 requires an argument" >&2; exit 1; }
            if ! [[ "$2" =~ ^[0-9]+$ ]]; then
                echo "Error: --jobs requires a positive integer, got '$2'" >&2; exit 1
            fi
            JOBS="$2"; shift 2 ;;
        -h|--help)
            show_help ;;
        --)
            shift; SRCDIR="$1"; shift; break ;;
        -*)
            echo "Unknown option: $1" >&2; exit 1 ;;
        *)
            if [[ -n "$SRCDIR" ]]; then
                echo "Error: unexpected argument '$1' (source directory already set to '$SRCDIR')" >&2
                exit 1
            fi
            SRCDIR="$1"; shift ;;
    esac
done

SRCDIR="${SRCDIR:-.}"
if [[ ! -d "$SRCDIR" ]]; then
    echo "Error: source directory '$SRCDIR' does not exist or is not a directory" >&2
    exit 1
fi
SRCDIR="$(cd "$SRCDIR" && pwd)"

if [[ "$MODE" != "generate" ]]; then
    COPYRIGHT_FILE="$SRCDIR/debian/copyright"
    if [[ ! -f "$COPYRIGHT_FILE" ]]; then
        echo "Error: $COPYRIGHT_FILE not found." >&2
        echo "  Use 'generate' mode to create one, or run from a debian source tree." >&2
        exit 1
    fi
    # Detect pre-DEP-5 (non-machine-readable) copyright files.
    # DEP-5 files must start with "Format:" in the first paragraph.
    _first_line="$(head -1 "$COPYRIGHT_FILE")"
    if [[ ! "$_first_line" =~ ^Format: ]]; then
        echo "Warning: $COPYRIGHT_FILE is not in DEP-5 machine-readable format." >&2
        echo "  (First line should be 'Format: https://...', found: '${_first_line:0:60}')" >&2
        echo "" >&2
        echo "  This appears to be a pre-DEP-5 free-text copyright file." >&2
        echo "  It must be regenerated in DEP-5 format for copyright-audit to work." >&2
        echo "" >&2
        if [[ "$FIX_MODE" == "true" ]]; then
            if [[ "$YES_MODE" == "true" ]]; then
                _regen_answer="y"
            else
                printf "  Regenerate debian/copyright from scratch? [y/N] " >&2
                read -r _regen_answer
            fi
            if [[ "$_regen_answer" =~ ^[Yy] ]]; then
                echo "  Switching to generate mode..." >&2
                MODE="generate"
                COPYRIGHT_FILE=""
            else
                echo "  Cannot proceed with a non-DEP-5 file. Exiting." >&2
                exit 1
            fi
        else
            echo "  Re-run with --fix to regenerate, or use 'generate' mode directly." >&2
            exit 1
        fi
    fi
else
    COPYRIGHT_FILE=""
fi

# ── Detect available scanner ──────────────────────────────────────────
SCANNER=""
if command -v scancode >/dev/null 2>&1; then
    SCANNER="scancode"
elif command -v licensecheck >/dev/null 2>&1; then
    SCANNER="licensecheck"
else
    echo "Error: No license scanner found." >&2
    echo "  Install one of:" >&2
    echo "    pip install scancode-toolkit   (recommended, uses SPDX database)" >&2
    echo "    sudo apt install licensecheck  (basic, Debian-native)" >&2
    exit 1
fi

if ! command -v python3 >/dev/null 2>&1; then
    echo "Error: python3 is required." >&2
    exit 1
fi

if [[ -n "$CACHE_DIR" ]]; then
    mkdir -p "$CACHE_DIR"
    _WORKDIR="$(cd "$CACHE_DIR" && pwd)"
    # Don't delete the cache dir on exit
    trap '' EXIT
else
    _WORKDIR="$(mktemp -d)"
    trap 'rm -rf "$_WORKDIR"' EXIT
fi

# ── Default exclusions for VCS / build artifacts ─────────────────────
DEFAULT_EXCLUDES=(".git" ".git/*" ".svn" ".svn/*" ".hg" ".hg/*"
    "node_modules/*" "__pycache__/*"
    ".tox/*" "*.pyc" ".eggs/*" "*.egg-info/*" "_build/*" "build/*")
for _pat in "${DEFAULT_EXCLUDES[@]}"; do
    EXCLUDE_PATTERNS+=("$_pat")
done

# Exclude the copyright file itself (and derivatives like .bak, .raw)
# from scanning to prevent a feedback loop where declared
# holders/licenses get re-detected.
if [[ -n "$COPYRIGHT_FILE" ]]; then
    _relcop="${COPYRIGHT_FILE#"$SRCDIR/"}"
    EXCLUDE_PATTERNS+=("$_relcop" "${_relcop}.*" "${_relcop}.bak")
fi

echo "=== Debian Copyright Tool ===" >&2
echo "Mode:    $MODE" >&2
echo "Source:  $SRCDIR" >&2
echo "Scanner: $SCANNER" >&2
[[ -n "$COPYRIGHT_FILE" ]] && echo "Copyright: $COPYRIGHT_FILE" >&2
if [[ ${#EXCLUDE_PATTERNS[@]} -gt 0 ]]; then
    echo "Excluding: ${EXCLUDE_PATTERNS[*]}" >&2
fi
echo "" >&2

# ── Run scanner ───────────────────────────────────────────────────────
cd "$SRCDIR"

# ── Decopy-accelerated generation ─────────────────────────────────────
# In generate mode, run decopy FIRST (fast) to produce the initial
# debian/copyright.  The slower scanner runs after so its results can
# harden the file via check+fix.
DECOPY_GENERATED="false"
if [[ "$MODE" == "generate" ]] && command -v decopy >/dev/null 2>&1; then
    _GEN_TARGET="${OUTPUT:-$SRCDIR/debian/copyright}"
    mkdir -p "$(dirname "$_GEN_TARGET")" 2>/dev/null || true
    echo "Generating initial copyright with decopy..." >&2
    if decopy --progress . > "$_GEN_TARGET" && [[ -s "$_GEN_TARGET" ]]; then
        echo "Hardening with scanner cross-validation and DEP-5 fixes..." >&2
        COPYRIGHT_FILE="$_GEN_TARGET"
        MODE="check"
        FIX_MODE="true"
        YES_MODE="true"
        DECOPY_GENERATED="true"
        # Exclude the generated file from scanning to prevent feedback loop
        _relcop="${COPYRIGHT_FILE#"$SRCDIR/"}"
        EXCLUDE_PATTERNS+=("$_relcop" "${_relcop}.*" "${_relcop}.bak")
    else
        echo "decopy produced no output; falling back to built-in generator." >&2
        rm -f "$_GEN_TARGET"
    fi
fi

if [[ "$SCANNER" == "scancode" ]]; then
    if [[ -n "$CACHE_DIR" && -s "$_WORKDIR/scancode.json" ]]; then
        echo "Using cached ScanCode results from $CACHE_DIR" >&2
    else
        echo "Running ScanCode (SPDX database)... this may take several minutes." >&2
        SC_ARGS=(--license --copyright --json "$_WORKDIR/scancode.json")
        if [[ -n "$JOBS" ]]; then
            SC_ARGS+=(-n "$JOBS")
        fi
        # Pass exclude patterns to scancode for early filtering
        for _pat in "${EXCLUDE_PATTERNS[@]}"; do
            SC_ARGS+=(--ignore "$_pat")
        done
        # ScanCode needs a real TTY on stderr for its progress bar.
        # Let stderr go straight to the terminal; capture exit status only.
        scancode "${SC_ARGS[@]}" . && _sc_rc=0 || _sc_rc=$?
        if [[ $_sc_rc -ne 0 ]]; then
            echo "Warning: ScanCode exited with code $_sc_rc. Results may be incomplete." >&2
        fi
    fi
    SCAN_FILE="$_WORKDIR/scancode.json"

    # Run licensecheck as a sanity check if available
    if [[ -n "$CACHE_DIR" && -s "$_WORKDIR/licensecheck_sanity.txt" ]]; then
        echo "Using cached licensecheck sanity results from $CACHE_DIR" >&2
    elif command -v licensecheck >/dev/null 2>&1; then
        echo "Running licensecheck as sanity check..." >&2
        licensecheck -r --deb-machine . 2>/dev/null \
            | sed 's/[\x00-\x08\x0e-\x1f]//g' \
            > "$_WORKDIR/licensecheck_sanity.txt" || true
    fi

    # Run decopy as a sanity check if available and in check/review mode.
    # When decopy generated the file we still run it in --mode full so
    # cross-validation data is available for Fix 1c.
    if [[ -n "$CACHE_DIR" && -s "$_WORKDIR/decopy.txt" ]]; then
        echo "Using cached decopy results from $CACHE_DIR" >&2
    elif [[ "$MODE" != "generate" ]] \
       && command -v decopy >/dev/null 2>&1 \
       && [[ -n "$COPYRIGHT_FILE" ]]; then
        echo "Running decopy as sanity check..." >&2
        decopy --mode full --progress --copyright-file "$COPYRIGHT_FILE" . \
            > "$_WORKDIR/decopy.txt" || true
    fi
else
    if [[ -n "$CACHE_DIR" && -s "$_WORKDIR/licensecheck.txt" ]]; then
        echo "Using cached licensecheck results from $CACHE_DIR" >&2
    else
        echo "Running licensecheck..." >&2
        if ! licensecheck -r --deb-machine . 2>"$_WORKDIR/licensecheck.err" \
            | sed 's/[\x00-\x08\x0e-\x1f]//g' \
            > "$_WORKDIR/licensecheck.txt"; then
            echo "Warning: licensecheck exited with errors. Results may be incomplete." >&2
            head -20 "$_WORKDIR/licensecheck.err" >&2
        fi
    fi
    SCAN_FILE="$_WORKDIR/licensecheck.txt"

    # Also run decopy if available and in check/review mode
    # When decopy generated the file we still run it as a sanity check
    # in --mode full so cross-validation data is available for Fix 1c.
    if [[ -n "$CACHE_DIR" && -s "$_WORKDIR/decopy.txt" ]]; then
        echo "Using cached decopy results from $CACHE_DIR" >&2
    elif [[ "$MODE" != "generate" ]] \
       && command -v decopy >/dev/null 2>&1; then
        echo "Running decopy as sanity check..." >&2
        decopy --mode full --progress --copyright-file "$COPYRIGHT_FILE" . \
            > "$_WORKDIR/decopy.txt" || true
    fi
fi

echo "Analyzing..." >&2
echo "" >&2

# ── Pass state to Python ─────────────────────────────────────────────
# ── Resolve symlinks flag for Python ──────────────────────────────────
EXCLUDE_STR="$(printf '%s\n' "${EXCLUDE_PATTERNS[@]+"${EXCLUDE_PATTERNS[@]}"}")"

# ── Detect packager identity ─────────────────────────────────────────
# Priority: DEBFULLNAME/DEBEMAIL > git config > system user
PACKAGER_NAME="${DEBFULLNAME:-}"
PACKAGER_EMAIL="${DEBEMAIL:-}"

if [[ -z "$PACKAGER_NAME" ]]; then
    PACKAGER_NAME="$(git config --get user.name 2>/dev/null || true)"
fi
if [[ -z "$PACKAGER_EMAIL" ]]; then
    PACKAGER_EMAIL="$(git config --get user.email 2>/dev/null || true)"
fi
if [[ -z "$PACKAGER_NAME" ]]; then
    PACKAGER_NAME="$(getent passwd "$USER" 2>/dev/null | cut -d: -f5 | cut -d, -f1 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' || true)"
fi
if [[ -z "$PACKAGER_NAME" ]]; then
    PACKAGER_NAME="$USER"
fi

PACKAGER_YEAR="$(date +%Y)"
HAS_SANITY_CHECK="false"
SANITY_TOOLS=""
if [[ "$SCANNER" == "scancode" ]]; then
    if [[ -s "$_WORKDIR/licensecheck_sanity.txt" ]]; then
        HAS_SANITY_CHECK="true"
        SANITY_TOOLS="licensecheck"
    fi
    if [[ -s "$_WORKDIR/decopy.txt" ]]; then
        HAS_SANITY_CHECK="true"
        SANITY_TOOLS="${SANITY_TOOLS:+$SANITY_TOOLS,}decopy"
    fi
elif [[ "$SCANNER" == "licensecheck" && -s "$_WORKDIR/decopy.txt" ]]; then
    HAS_SANITY_CHECK="true"
    SANITY_TOOLS="decopy"
fi
export EXCLUDE_STR VERBOSE FIX_MODE YES_MODE NO_FETCH SCANNER MODE COPYRIGHT_FILE OUTPUT EXPORT
export PACKAGER_NAME PACKAGER_EMAIL PACKAGER_YEAR HAS_SANITY_CHECK SANITY_TOOLS DECOPY_GENERATED

python3 - "$SCAN_FILE" << 'PYEOF'
import datetime, json, os, re, subprocess, sys, fnmatch, textwrap, unicodedata, glob as globmod
import xml.etree.ElementTree as ET
from collections import defaultdict, OrderedDict
from pathlib import Path
from urllib.request import urlopen, Request
from urllib.error import URLError

scan_file = sys.argv[1]
scanner = os.environ.get("SCANNER", "licensecheck")
mode = os.environ.get("MODE", "check")
copyright_file = os.environ.get("COPYRIGHT_FILE", "")
output_file = os.environ.get("OUTPUT", "")
verbose = os.environ.get("VERBOSE", "false") == "true"
fix_mode = os.environ.get("FIX_MODE", "false") == "true"
yes_mode = os.environ.get("YES_MODE", "false") == "true"
no_fetch = os.environ.get("NO_FETCH", "false") == "true"
export_file = os.environ.get("EXPORT", "")
packager_name = os.environ.get("PACKAGER_NAME", "")
packager_email = os.environ.get("PACKAGER_EMAIL", "")
packager_year = os.environ.get("PACKAGER_YEAR", "")
has_sanity_check = os.environ.get("HAS_SANITY_CHECK", "false") == "true"
sanity_tools = [t for t in os.environ.get("SANITY_TOOLS", "").split(",") if t]
_decopy_generated = os.environ.get("DECOPY_GENERATED", "false") == "true"

# ── Detect native Debian packages ─────────────────────────────────────
# Native packages ("3.0 (native)") have no upstream/debian split, so a
# separate Files: debian/* stanza is inappropriate.
def _is_native_package():
    try:
        fmt = Path("debian/source/format").read_text(encoding="utf-8").strip()
        return fmt == "3.0 (native)"
    except (OSError, UnicodeDecodeError):
        return False

is_native = _is_native_package()

# ══════════════════════════════════════════════════════════════════════
# Built-in license texts for non-SPDX licenses
# ══════════════════════════════════════════════════════════════════════
_BUILTIN_LICENSE_TEXTS = {
    "Unsplash": (
        "Unsplash License\n"
        "\n"
        "Unsplash grants you an irrevocable, nonexclusive, worldwide copyright\n"
        "license to download, copy, modify, distribute, perform, and use images\n"
        "from Unsplash for free, including for commercial purposes, without\n"
        "permission from or attributing the photographer or Unsplash. This\n"
        "license does not include the right to compile images from Unsplash to\n"
        "replicate a similar or competing service.\n"
        "\n"
        "https://unsplash.com/license"
    ),
}

# ══════════════════════════════════════════════════════════════════════
# License text fetcher — downloads from SPDX or Creative Commons
# ══════════════════════════════════════════════════════════════════════
_license_text_cache = {}

def fetch_license_text(license_id):
    """Fetch full license text from built-in texts, SPDX, or Creative Commons.

    Returns the license text as a string, or None if unavailable.
    Results are cached so each license is fetched at most once.
    """
    # Built-in texts are always available regardless of --no-fetch
    if license_id in _BUILTIN_LICENSE_TEXTS:
        return _BUILTIN_LICENSE_TEXTS[license_id]

    if no_fetch:
        return None
    if license_id in _license_text_cache:
        return _license_text_cache[license_id]

    text = None

    # Try Creative Commons URL first for CC licenses
    if license_id.upper().startswith("CC-") and license_id.upper() != "CC0-1.0":
        text = _fetch_cc_text(license_id)

    # Try SPDX for everything (including CC as fallback)
    if text is None:
        text = _fetch_spdx_text(license_id)

    _license_text_cache[license_id] = text
    return text

def _fetch_cc_text(license_id):
    """Fetch CC license text from creativecommons.org."""
    tag = license_id.lower().replace("cc-", "").rsplit("-", 1)
    variant = tag[0] if tag else license_id.lower()
    version = tag[1] if len(tag) > 1 else "4.0"
    url = f"https://creativecommons.org/licenses/{variant}/{version}/legalcode.txt"
    return _fetch_url(url)

# Reverse mapping from DEP-5 names back to SPDX identifiers for API lookups
_DEP5_TO_SPDX = {
    "Expat": "MIT",
    "Artistic": "Artistic-1.0",
    "GPL-1": "GPL-1.0-only", "GPL-1+": "GPL-1.0-or-later",
    "GPL-2": "GPL-2.0-only", "GPL-2+": "GPL-2.0-or-later",
    "GPL-3": "GPL-3.0-only", "GPL-3+": "GPL-3.0-or-later",
    "LGPL-2": "LGPL-2.0-only", "LGPL-2+": "LGPL-2.0-or-later",
    "LGPL-2.1": "LGPL-2.1-only", "LGPL-2.1+": "LGPL-2.1-or-later",
    "LGPL-3": "LGPL-3.0-only", "LGPL-3+": "LGPL-3.0-or-later",
    "AGPL-3": "AGPL-3.0-only", "AGPL-3+": "AGPL-3.0-or-later",
    "GFDL": "GFDL-1.3-only",
    "GFDL-1.1": "GFDL-1.1-only", "GFDL-1.1+": "GFDL-1.1-or-later",
    "GFDL-1.2": "GFDL-1.2-only", "GFDL-1.2+": "GFDL-1.2-or-later",
    "GFDL-1.3": "GFDL-1.3-only", "GFDL-1.3+": "GFDL-1.3-or-later",
}

def _fetch_spdx_text(license_id):
    """Fetch license text from the SPDX license list API."""
    # Map DEP-5 names back to SPDX identifiers for the API
    spdx_id = _DEP5_TO_SPDX.get(license_id, license_id)
    url = f"https://spdx.org/licenses/{spdx_id}.json"
    body = _fetch_url(url)
    if body is None:
        return None
    try:
        data = json.loads(body)
        return data.get("licenseText", None)
    except (json.JSONDecodeError, KeyError):
        return None

def _fetch_url(url, timeout=15):
    """GET a URL and return the body text, or None on failure."""
    try:
        req = Request(url, headers={"User-Agent": "copyright-audit/1.0"})
        with urlopen(req, timeout=timeout) as resp:
            return resp.read().decode("utf-8", errors="replace")
    except (URLError, OSError, ValueError):
        return None

def format_license_body(text, max_col=80):
    """Format fetched license text for DEP-5 (indent each line with a space).
    Word-wraps lines that would exceed max_col."""
    body_width = max_col - 1  # 1-space DEP-5 indent leaves this much for content
    lines = []
    for line in text.splitlines():
        if line.strip() == "":
            lines.append(" .")
        elif len(line) <= body_width:
            lines.append(" " + line)
        else:
            stripped = line.lstrip()
            leading = line[:len(line) - len(stripped)]
            avail = body_width - len(leading)
            if avail < 20:
                lines.append(" " + line)
            else:
                wrapped = textwrap.wrap(
                    stripped, width=avail,
                    break_long_words=False, break_on_hyphens=False)
                for wl in (wrapped or [stripped]):
                    lines.append(" " + leading + wl)
    return "\n".join(lines)

# ── Standard DEP-5 boilerplate preambles for common licenses ─────────
# Each entry maps a license-name prefix to (preamble_template, version_word).
# The preamble_template uses {version} for the version number and
# {or_later} for the "or later" clause.  The slug for common-licenses
# is derived automatically (strip trailing +).

def _gpl_preamble(version, or_later):
    return (
        f" This program is free software: you can redistribute it and/or modify\n"
        f" it under the terms of the GNU General Public License as published by\n"
        f" the Free Software Foundation, version {version}{or_later}.\n"
        f" .\n"
        f" This package is distributed in the hope that it will be useful,\n"
        f" but WITHOUT ANY WARRANTY; without even the implied warranty of\n"
        f" MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n"
        f" GNU General Public License for more details.\n"
        f" .\n"
        f" You should have received a copy of the GNU General Public License\n"
        f" along with this program. If not, see <https://www.gnu.org/licenses/>."
    )

def _lgpl_preamble(version, or_later):
    return (
        f" This program is free software: you can redistribute it and/or modify\n"
        f" it under the terms of the GNU Lesser General Public License as\n"
        f" published by the Free Software Foundation, version {version}{or_later}.\n"
        f" .\n"
        f" This package is distributed in the hope that it will be useful,\n"
        f" but WITHOUT ANY WARRANTY; without even the implied warranty of\n"
        f" MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n"
        f" GNU Lesser General Public License for more details.\n"
        f" .\n"
        f" You should have received a copy of the GNU Lesser General Public License\n"
        f" along with this program. If not, see <https://www.gnu.org/licenses/>."
    )

def _agpl_preamble(version, or_later):
    return (
        f" This program is free software: you can redistribute it and/or modify\n"
        f" it under the terms of the GNU Affero General Public License as\n"
        f" published by the Free Software Foundation, version {version}{or_later}.\n"
        f" .\n"
        f" This package is distributed in the hope that it will be useful,\n"
        f" but WITHOUT ANY WARRANTY; without even the implied warranty of\n"
        f" MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n"
        f" GNU Affero General Public License for more details.\n"
        f" .\n"
        f" You should have received a copy of the GNU Affero General Public License\n"
        f" along with this program. If not, see <https://www.gnu.org/licenses/>."
    )

def _gfdl_preamble(version, or_later):
    return (
        f" Permission is granted to copy, distribute and/or modify this document\n"
        f" under the terms of the GNU Free Documentation License, Version {version}{or_later},\n"
        f" with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts."
    )

def _apache_preamble(version, or_later):
    return (
        f" Licensed under the Apache License, Version {version} (the \"License\");\n"
        f" you may not use this file except in compliance with the License.\n"
        f" You may obtain a copy of the License at\n"
        f" .\n"
        f"     https://www.apache.org/licenses/LICENSE-2.0\n"
        f" .\n"
        f" Unless required by applicable law or agreed to in writing, software\n"
        f" distributed under the License is distributed on an \"AS IS\" BASIS,\n"
        f" WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n"
        f" See the License for the specific language governing permissions and\n"
        f" limitations under the License."
    )

def _mpl_preamble(version, or_later):
    return (
        f" This Source Code Form is subject to the terms of the Mozilla Public\n"
        f" License, v. {version}. If a copy of the MPL was not distributed with this\n"
        f" file, You can obtain one at https://mozilla.org/MPL/{version}/."
    )

def _artistic_preamble(version, or_later):
    return (
        f" This program is free software; you can redistribute it and/or modify\n"
        f" it under the terms of the Artistic License."
    )

def _bsd_preamble(version, or_later):
    return (
        f" Redistribution and use in source and binary forms, with or without\n"
        f" modification, are permitted provided that the conditions in the\n"
        f" BSD license are met."
    )

def _cc0_preamble(version, or_later):
    return (
        f" To the extent possible under law, the author(s) have dedicated all\n"
        f" copyright and related and neighboring rights to this software to the\n"
        f" public domain worldwide. This software is distributed without any warranty."
    )

# Map license-name prefix → preamble function
_PREAMBLE_MAP = {
    "GPL": _gpl_preamble,
    "LGPL": _lgpl_preamble,
    "AGPL": _agpl_preamble,
    "GFDL": _gfdl_preamble,
    "Apache": _apache_preamble,
    "MPL": _mpl_preamble,
    "Artistic": _artistic_preamble,
    "BSD": _bsd_preamble,
    "CC0": _cc0_preamble,
}

def _wrap_dep5_body(text, max_col=79):
    """Re-wrap any lines in DEP-5 body text that exceed *max_col* chars."""
    result = []
    for line in text.split('\n'):
        if line == ' .' or len(line) <= max_col:
            result.append(line)
        else:
            content = line.lstrip(' ')
            wrapped = textwrap.wrap(content, width=max_col - 1,
                                    break_long_words=False,
                                    break_on_hyphens=False)
            result.extend(' ' + w for w in wrapped)
    return '\n'.join(result)

def _dep5_common_license_body(lic_name):
    """Build the full DEP-5 license body for a license in common-licenses.
    Returns the body text (with leading-space DEP-5 indentation) including
    the standard preamble and the common-licenses pointer."""
    slug = lic_name.rstrip("+")
    or_later = " or (at your option) any later version" if lic_name.endswith("+") else ""

    # Parse version number from the license name
    m = re.match(r'[A-Za-z]+-(.+)', slug)
    version = m.group(1) if m else ""

    # Find the matching preamble function
    preamble_fn = None
    for prefix, fn in _PREAMBLE_MAP.items():
        if lic_name.startswith(prefix):
            preamble_fn = fn
            break

    if preamble_fn:
        preamble = preamble_fn(version, or_later)
        # GFDL and CC0 don't need the common-licenses pointer in all cases,
        # but including it is standard practice.
        full_name_map = {
            "GPL": "GNU General Public License",
            "LGPL": "GNU Lesser General Public License",
            "AGPL": "GNU Affero General Public License",
            "GFDL": "GNU Free Documentation License",
            "Apache": "Apache License",
            "MPL": "Mozilla Public License",
            "Artistic": "Artistic License",
            "BSD": "BSD License",
            "CC0": "Creative Commons Zero License",
        }
        full_name = "this license"
        for prefix, name in full_name_map.items():
            if lic_name.startswith(prefix):
                full_name = f"the {name}"
                if version:
                    full_name += f" version {version}"
                break
        body = (
            f"{preamble}\n"
            f" .\n"
            f" On Debian systems, the complete text of {full_name}"
            f" can be found in \"/usr/share/common-licenses/{slug}\"."
        )
        return _wrap_dep5_body(body)
    else:
        # Fallback for unrecognized common-licenses entries
        return (
            f" On Debian systems, the full text of this license can be found in\n"
            f" `/usr/share/common-licenses/{slug}'."
        )

exclude_raw = os.environ.get("EXCLUDE_STR", "").strip()
excludes = [p for p in exclude_raw.split("\n") if p]

def is_excluded(filepath):
    fp = filepath.lstrip("./")
    # Always exclude VCS internals even if not in the exclude list
    if fp == ".git" or fp.startswith(".git/"):
        return True
    return any(fnmatch.fnmatch(fp, pat) for pat in excludes)

# Patterns for metadata/infrastructure files that should be covered by
# the Files: * catch-all rather than receiving their own stanzas.
_METADATA_GLOBS = (
    "*/metadata.json",
    "*/metadata.desktop",
    "*/contents/screenshot.png",
    "usr/share/gnome-background-properties/*",
    "usr/share/plymouth/themes/*/*.plymouth",
    "etc/*",
    "po/*",
    "LICENSES/*",       # REUSE spec: whole license text files
    "licenses/*",
)
_METADATA_BASENAMES = frozenset({
    "AUTHORS", "COPYING", "COPYING.txt", "LICENSE", "LICENSE.txt",
    "LICENSE.md", "LICENCE", "LICENCE.txt",
    "COPYRIGHT", "COPYRIGHT.txt",
    "NEWS", "README", "README.md",
    "ChangeLog", "POTFILES.in",
})

def is_metadata_file(filepath):
    """Return True if *filepath* is a metadata or infrastructure file
    that should be covered by the Files: * catch-all stanza rather than
    getting its own per-file stanza.  These include KDE metadata.json,
    wallpaper screenshot.png thumbnails, GNOME XML descriptors, packaging
    boilerplate, configuration files, and translation catalogs."""
    fp = filepath.lstrip("./")
    if os.path.basename(fp) in _METADATA_BASENAMES:
        return True
    # Symlinks are never the canonical copyright target
    if os.path.islink(fp):
        return True
    return any(fnmatch.fnmatch(fp, pat) for pat in _METADATA_GLOBS)

# ══════════════════════════════════════════════════════════════════════
# SPDX license ID normalization (maps scanner output -> DEP-5 names)
# ══════════════════════════════════════════════════════════════════════
SPDX_TO_DEP5 = {
    # MIT / Expat variants
    "mit": "Expat", "expat": "Expat", "x11": "Expat",
    "mit/x11": "Expat", "mit~old": "Expat", "mit-cmu": "Expat",
    "mit-0": "MIT-0",
    # BSD variants
    "bsd-3-clause": "BSD-3-Clause", "bsd-3-clause-no-nuclear-license": "BSD-3-Clause",
    "bsd-2-clause": "BSD-2-Clause", "bsd-2-clause-views": "BSD-2-Clause",
    "bsd-2-clause-freebsd": "BSD-2-Clause",
    "0bsd": "0BSD", "bsd-1-clause": "BSD-1-Clause",
    # Apache
    "apache-2.0": "Apache-2.0", "apache-1.1": "Apache-1.1",
    # GPL family
    "gpl-1.0-only": "GPL-1", "gpl-1.0-or-later": "GPL-1+",
    "gpl-2.0-only": "GPL-2", "gpl-2.0-or-later": "GPL-2+",
    "gpl-2": "GPL-2", "gpl-2+": "GPL-2+",
    "gpl-3.0-only": "GPL-3", "gpl-3.0-or-later": "GPL-3+",
    "gpl-3": "GPL-3", "gpl-3+": "GPL-3+",
    # LGPL family
    "lgpl-2.0-only": "LGPL-2", "lgpl-2.0-or-later": "LGPL-2+",
    "lgpl-2": "LGPL-2", "lgpl-2+": "LGPL-2+",
    "lgpl-2.1-only": "LGPL-2.1", "lgpl-2.1-or-later": "LGPL-2.1+",
    "lgpl-2.1": "LGPL-2.1", "lgpl-2.1+": "LGPL-2.1+",
    "lgpl-3.0-only": "LGPL-3", "lgpl-3.0-or-later": "LGPL-3+",
    "lgpl-3": "LGPL-3", "lgpl-3+": "LGPL-3+",
    # AGPL
    "agpl-3.0-only": "AGPL-3", "agpl-3.0-or-later": "AGPL-3+",
    "agpl-3": "AGPL-3", "agpl-3+": "AGPL-3+",
    # MPL
    "mpl-1.1": "MPL-1.1", "mpl-2.0": "MPL-2.0",
    # Creative Commons (all versions 1.0 through 4.0)
    # Include versionless aliases (e.g. "cc-by-sa-3" → "CC-BY-SA-3.0")
    # because decopy and other tools sometimes omit the trailing ".0".
    "cc0-1.0": "CC0-1.0", "cc-pddc": "CC-PDDC",
    "cc-by-1.0": "CC-BY-1.0", "cc-by-2.0": "CC-BY-2.0",
    "cc-by-2.5": "CC-BY-2.5", "cc-by-3.0": "CC-BY-3.0", "cc-by-4.0": "CC-BY-4.0",
    "cc-by-1": "CC-BY-1.0", "cc-by-2": "CC-BY-2.0", "cc-by-3": "CC-BY-3.0", "cc-by-4": "CC-BY-4.0",
    "cc-by-sa-1.0": "CC-BY-SA-1.0", "cc-by-sa-2.0": "CC-BY-SA-2.0",
    "cc-by-sa-2.5": "CC-BY-SA-2.5", "cc-by-sa-3.0": "CC-BY-SA-3.0", "cc-by-sa-4.0": "CC-BY-SA-4.0",
    "cc-by-sa-1": "CC-BY-SA-1.0", "cc-by-sa-2": "CC-BY-SA-2.0", "cc-by-sa-3": "CC-BY-SA-3.0", "cc-by-sa-4": "CC-BY-SA-4.0",
    "cc-by-nc-1.0": "CC-BY-NC-1.0", "cc-by-nc-2.0": "CC-BY-NC-2.0",
    "cc-by-nc-2.5": "CC-BY-NC-2.5", "cc-by-nc-3.0": "CC-BY-NC-3.0", "cc-by-nc-4.0": "CC-BY-NC-4.0",
    "cc-by-nc-1": "CC-BY-NC-1.0", "cc-by-nc-2": "CC-BY-NC-2.0", "cc-by-nc-3": "CC-BY-NC-3.0", "cc-by-nc-4": "CC-BY-NC-4.0",
    "cc-by-nd-1.0": "CC-BY-ND-1.0", "cc-by-nd-2.0": "CC-BY-ND-2.0",
    "cc-by-nd-2.5": "CC-BY-ND-2.5", "cc-by-nd-3.0": "CC-BY-ND-3.0", "cc-by-nd-4.0": "CC-BY-ND-4.0",
    "cc-by-nd-1": "CC-BY-ND-1.0", "cc-by-nd-2": "CC-BY-ND-2.0", "cc-by-nd-3": "CC-BY-ND-3.0", "cc-by-nd-4": "CC-BY-ND-4.0",
    "cc-by-nc-sa-1.0": "CC-BY-NC-SA-1.0", "cc-by-nc-sa-2.0": "CC-BY-NC-SA-2.0",
    "cc-by-nc-sa-2.5": "CC-BY-NC-SA-2.5", "cc-by-nc-sa-3.0": "CC-BY-NC-SA-3.0", "cc-by-nc-sa-4.0": "CC-BY-NC-SA-4.0",
    "cc-by-nc-sa-1": "CC-BY-NC-SA-1.0", "cc-by-nc-sa-2": "CC-BY-NC-SA-2.0", "cc-by-nc-sa-3": "CC-BY-NC-SA-3.0", "cc-by-nc-sa-4": "CC-BY-NC-SA-4.0",
    "cc-by-nc-nd-1.0": "CC-BY-NC-ND-1.0", "cc-by-nc-nd-2.0": "CC-BY-NC-ND-2.0",
    "cc-by-nc-nd-2.5": "CC-BY-NC-ND-2.5", "cc-by-nc-nd-3.0": "CC-BY-NC-ND-3.0", "cc-by-nc-nd-4.0": "CC-BY-NC-ND-4.0",
    "cc-by-nc-nd-1": "CC-BY-NC-ND-1.0", "cc-by-nc-nd-2": "CC-BY-NC-ND-2.0", "cc-by-nc-nd-3": "CC-BY-NC-ND-3.0", "cc-by-nc-nd-4": "CC-BY-NC-ND-4.0",
    # GFDL family
    "gfdl-1.1-only": "GFDL-1.1", "gfdl-1.1-or-later": "GFDL-1.1+",
    "gfdl-1.1": "GFDL-1.1", "gfdl-1.1+": "GFDL-1.1+",
    "gfdl-1.1-no-invariants-only": "GFDL-1.1", "gfdl-1.1-no-invariants-or-later": "GFDL-1.1+",
    "gfdl-1.1-invariants-only": "GFDL-1.1", "gfdl-1.1-invariants-or-later": "GFDL-1.1+",
    "gfdl-1.2-only": "GFDL-1.2", "gfdl-1.2-or-later": "GFDL-1.2+",
    "gfdl-1.2": "GFDL-1.2", "gfdl-1.2+": "GFDL-1.2+",
    "gfdl-1.2-no-invariants-only": "GFDL-1.2", "gfdl-1.2-no-invariants-or-later": "GFDL-1.2+",
    "gfdl-1.2-invariants-only": "GFDL-1.2", "gfdl-1.2-invariants-or-later": "GFDL-1.2+",
    "gfdl-1.3-only": "GFDL-1.3", "gfdl-1.3-or-later": "GFDL-1.3+",
    "gfdl-1.3": "GFDL-1.3", "gfdl-1.3+": "GFDL-1.3+",
    "gfdl-1.3-no-invariants-only": "GFDL-1.3", "gfdl-1.3-no-invariants-or-later": "GFDL-1.3+",
    "gfdl-1.3-invariants-only": "GFDL-1.3", "gfdl-1.3-invariants-or-later": "GFDL-1.3+",
    # EPL / Eclipse
    "epl-1.0": "EPL-1.0", "epl-2.0": "EPL-2.0",
    # CDDL
    "cddl-1.0": "CDDL-1.0", "cddl-1.1": "CDDL-1.1",
    # Other common licenses
    "isc": "ISC",
    "zlib": "Zlib",
    "ofl-1.0": "OFL-1.0", "ofl-1.1": "OFL-1.1",
    "sgi-b-2.0": "SGI-B-2.0",
    "hpnd": "HPND", "hpnd-sell-variant": "HPND-sell-variant",
    "bsl-1.0": "BSL-1.0", "busl-1.1": "BUSL-1.1",
    "unlicense": "Unlicense",
    "public-domain": "public-domain",
    "fsfap": "FSFAP", "fsful": "FSFUL", "fsfullr": "FSFULLR",
    "artistic-2.0": "Artistic-2.0", "artistic-1.0": "Artistic",
    "clartistic": "ClArtistic",
    "wtfpl": "WTFPL",
    "curl": "curl",
    "postgresql": "PostgreSQL",
    "eupl-1.2": "EUPL-1.2", "eupl-1.1": "EUPL-1.1",
    "cecill-2.1": "CeCILL-2.1", "cecill-b": "CeCILL-B", "cecill-c": "CeCILL-C",
    "python-2.0": "Python-2.0",
    "ruby": "Ruby",
    "ncsa": "NCSA",
    "unicode-dfs-2016": "Unicode-DFS-2016", "unicode-tou": "Unicode-TOU",
    # Additional well-known licenses
    "osl-3.0": "OSL-3.0", "osl-2.1": "OSL-2.1",
    "afl-3.0": "AFL-3.0",
    "qpl-1.0": "QPL-1.0",
    "lppl-1.3c": "LPPL-1.3c", "lppl-1.3a": "LPPL-1.3a", "lppl-1.2": "LPPL-1.2",
    "w3c": "W3C", "w3c-20150513": "W3C",
    "ntp": "NTP",
    "vim": "Vim",
    "sleepycat": "Sleepycat",
    "php-3.0": "PHP-3.0", "php-3.01": "PHP-3.01",
    "apsl-2.0": "APSL-2.0", "apsl-1.1": "APSL-1.1", "apsl-1.0": "APSL-1.0",
    "openssl": "OpenSSL",
    "oldap-2.8": "OLDAP-2.8",
    "ms-pl": "MS-PL", "ms-rl": "MS-RL",
    "sendmail": "Sendmail", "sendmail-8.23": "Sendmail",
    "libtiff": "libtiff",
    "beerware": "Beerware",
    "blessing": "blessing",
    "json": "JSON",
    "bsd-4-clause": "BSD-4-Clause",
    "agpl-1.0-only": "AGPL-1", "agpl-1.0-or-later": "AGPL-1+",
    "tcp-wrappers": "TCP-wrappers",
    "latex2e": "Latex2e",
    "sax-pd": "SAX-PD",
    "rsa-md": "RSA-MD",
    "ecl-2.0": "ECL-2.0",
    "cal-1.0": "CAL-1.0", "cal-1.0-combined-work-exception": "CAL-1.0",
    "mup": "MulanPSL-2.0", "mulanpsl-2.0": "MulanPSL-2.0",
    # Non-SPDX but well-known licenses
    "unsplash": "Unsplash", "unsplash-license": "Unsplash",
    # Bare / versionless Creative Commons (scanners sometimes emit these)
    "cc0": "CC0-1.0",
    "cc-by-sa": "CC-BY-SA",  # kept versionless — flagged by review
    "cc-by": "CC-BY",        # kept versionless — flagged by review
    # Creative Commons GPL wrappers (CC metadata URLs for GPL)
    "cc-gpl-2.0": "GPL-2", "cc-gpl-2": "GPL-2",
    "cc-gpl-3.0": "GPL-3", "cc-gpl-3": "GPL-3",
    "cc-lgpl-2.1": "LGPL-2.1", "cc-lgpl-3.0": "LGPL-3",
}

def to_dep5(raw):
    """Normalize a license identifier to DEP-5 convention."""
    s = raw.lower().strip().rstrip("+")
    plus = raw.strip().endswith("+") or "-or-later" in raw.lower()
    base = SPDX_TO_DEP5.get(s, None)
    if base is None:
        base = SPDX_TO_DEP5.get(s + "+" if plus else s, raw.strip())
    if plus and not base.endswith("+"):
        base += "+"
    return base

# Canonical source-code file extensions (single definition used everywhere)
SOURCE_EXTS = {
    ".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".hxx",
    ".py", ".sh", ".pl", ".pm", ".rb",
    ".java", ".js", ".ts", ".jsx", ".tsx",
    ".rs", ".go", ".swift", ".m", ".mm",
    ".cs", ".fs", ".vb",
}

def norm_cmp(lic):
    """Normalize for comparison only (lowercase, collapse variants)."""
    s = lic.lower().strip()
    has_linux_syscall_note = "linux-syscall-note" in s
    # Exceptions are not standalone licenses; compare by the base license.
    s = re.split(r'\s+with\s+', s, maxsplit=1, flags=re.IGNORECASE)[0]
    s = re.sub(r'\.0(?=$|[^0-9.])', '', s)
    s = s.replace("-or-later", "+").replace("-only", "")
    # GPL-2.0 WITH Linux-syscall-note is compatible with later GPL versions.
    # Normalize to GPL-2+ for compatibility checks to avoid false positives.
    if has_linux_syscall_note and s.startswith("gpl-2"):
        return "gpl-2+"
    ALIASES = {
        "expat": "expat", "mit": "expat", "x11": "expat",
        "mit/x11": "expat",
        "mit~old": "expat", "mit-cmu": "expat",
        "artistic": "artistic", "artistic-1": "artistic",
        "bsd": "bsd", "bsd-3-clause": "bsd-3-clause",
    }
    return ALIASES.get(s, s)

# ══════════════════════════════════════════════════════════════════════
# Parse scan results into unified format
# ══════════════════════════════════════════════════════════════════════
# Result: list of {"path": str, "licenses": [str], "copyrights": [str]}
# Low-confidence detections (scancode score < 50) are tracked separately.
scan_results = []
low_confidence = []  # [(path, license, score)]

MIN_CONFIDENCE = 50  # Minimum ScanCode match score to trust

if scanner == "scancode":
    try:
        with open(scan_file, encoding="utf-8") as f:
            data = json.load(f)
    except (json.JSONDecodeError, UnicodeDecodeError) as exc:
        print(f"Error: Failed to parse ScanCode output: {exc}", file=sys.stderr)
        print("  The scan may have been interrupted or produced invalid output.", file=sys.stderr)
        print("  Try re-running or check the ScanCode installation.", file=sys.stderr)
        sys.exit(1)
    # ScanCode scanning "." prefixes all paths with the directory basename.
    # Detect and strip this prefix so paths are relative to the source root.
    _sc_prefix = ""
    for entry in data.get("files", []):
        if entry.get("type") == "directory":
            _sc_prefix = entry["path"] + "/"
            break
    for entry in data.get("files", []):
        if entry.get("type") != "file":
            continue
        _raw_path = entry["path"]
        if _sc_prefix and _raw_path.startswith(_sc_prefix):
            _raw_path = _raw_path[len(_sc_prefix):]
        path = "./" + _raw_path.lstrip("./")
        if is_excluded(path):
            continue
        # Skip symlinks
        if entry.get("is_symlink", False):
            continue
        lics = []
        for det in entry.get("license_detections", []):
            # Filter by confidence score when available
            score = det.get("detection_log", [None])
            match_score = None
            for match in det.get("matches", []):
                s = match.get("score", 100)
                if match_score is None or s < match_score:
                    match_score = s
            if match_score is not None and match_score < MIN_CONFIDENCE:
                spdx = det.get("license_expression_spdx", "") or ""
                low_confidence.append((path, spdx or det.get("license_expression", ""), match_score))
                continue
            spdx = det.get("license_expression_spdx", "") or ""
            if spdx:
                # Split compound expressions (AND / OR);
                # WITH introduces an exception (not a standalone license)
                for part in re.split(r'\s+AND\s+|\s+OR\s+', spdx):
                    # Strip any "WITH exception" suffix
                    base = re.split(r'\s+WITH\s+', part, maxsplit=1)[0]
                    base = base.strip("() ")
                    if base and not base.startswith("LicenseRef-scancode-"):
                        lics.append(base)
            else:
                expr = det.get("license_expression", "") or ""
                for part in re.split(r'\s+AND\s+|\s+OR\s+', expr, flags=re.IGNORECASE):
                    # Strip any "WITH exception" suffix
                    base = re.split(r'\s+WITH\s+', part, maxsplit=1, flags=re.IGNORECASE)[0]
                    base = base.strip("() ")
                    if base and not base.lower().startswith("licenseref-scancode-"):
                        lics.append(base)
        cops = []
        for c in entry.get("copyrights", []):
            val = c.get("copyright", "")
            if val:
                cops.append(val)
        # De-duplicate
        lics = list(dict.fromkeys(lics))
        cops = list(dict.fromkeys(cops))
        scan_results.append({"path": path, "licenses": lics, "copyrights": cops})
else:
    # Parse licensecheck --deb-machine output
    with open(scan_file, encoding="utf-8") as f:
        content = f.read()
    SKIP_LIC = {"UNKNOWN"}
    for para in content.split("\n\n"):
        para = para.strip()
        if not para.startswith("Files:"):
            continue
        lines = para.split("\n")
        file_lines = []
        lic_line = None
        cop_line = None
        in_files = True
        for line in lines:
            if line.startswith("Files:"):
                rest = line[6:].strip()
                if rest:
                    file_lines.append(rest)
                in_files = True
            elif line.startswith("Copyright:"):
                in_files = False
                cop_line = line[10:].strip()
            elif line.startswith("License:"):
                in_files = False
                lic_line = line[8:].strip()
            elif in_files and line.startswith(" "):
                file_lines.append(line.strip())
        for fp in file_lines:
            if is_excluded(fp):
                continue
            lics = []
            if lic_line and lic_line not in SKIP_LIC:
                # licensecheck uses "and/or" for dual-license
                for part in re.split(r'\s+and/or\s+', lic_line):
                    part = part.strip("() ")
                    # Strip "with ... exception"
                    base = re.split(r'\s+with\s+', part, flags=re.IGNORECASE)[0].strip()
                    if base:
                        lics.append(base)
            cops = [cop_line] if cop_line else []
            scan_results.append({"path": fp, "licenses": lics, "copyrights": cops})

# Skip symlinks in licensecheck results too
scan_results = [r for r in scan_results if not os.path.islink(r["path"].lstrip("./"))]

# ── Filter false-positive licenses from patch/diff files ─────────────
# Scanners detect SPDX identifiers and license text inside diff context
# lines (lines starting with +, -, or space in unified diffs).  These
# belong to the file being patched, not the patch itself.  When a
# .patch/.diff file has scanner-detected licenses but zero copyrights,
# check whether ALL license matches fall on diff context lines; if so,
# clear the licenses so the file falls through to Files: *.
_PATCH_EXTS = {'.patch', '.diff'}
for _r in scan_results:
    _rp = _r["path"].lstrip("./")
    if not _r["licenses"] or _r["copyrights"]:
        continue
    if os.path.splitext(_rp)[1].lower() not in _PATCH_EXTS:
        continue
    # Read the patch file and check if it looks like a unified diff
    try:
        with open(_rp, 'r', errors='ignore') as _pf:
            _plines = _pf.readlines()
    except OSError:
        continue
    # A unified diff has "---"/"+++" header lines
    if not any(_l.startswith('--- ') for _l in _plines[:20]):
        continue
    # All reported licenses are false positives from diff context
    # if every line in the file containing a license keyword is a
    # diff context line (starts with +, -, or space).
    _lic_kw_re = re.compile(
        r'SPDX-License-Identifier|'
        r'GNU\s+(?:General|Lesser|Affero)|'
        r'(?:under|of)\s+the\s+(?:GNU|MIT|BSD|Apache)',
        re.IGNORECASE)
    _all_context = True
    for _ln in _plines:
        if _lic_kw_re.search(_ln):
            # In a unified diff, context lines start with ' ',
            # removed lines with '-', added lines with '+'.
            # Header lines (diff, index, ---/+++) and @@ lines
            # are not content.
            if not (_ln[:1] in (' ', '+', '-')
                    and not _ln.startswith('--- ')
                    and not _ln.startswith('+++ ')):
                _all_context = False
                break
    if _all_context:
        _r["licenses"] = []

# ── Filter binary-garbage copyright strings ──────────────────────────
# licensecheck sometimes interprets binary data inside PNG/GIF/etc.
# as copyright text, producing nonsense holders full of non-printable
# or non-ASCII characters.  Reject those strings early.
_PRINTABLE_RANGE = set(range(0x20, 0x7F)) | {0x09, 0x0A, 0x0D}  # tab, LF, CR

# Patterns that indicate a scanner false-positive: code fragments,
# template variables, format strings, and other non-person text that
# happen to appear near the word "copyright" in source files.
_CODE_FRAGMENT_RE = re.compile(
    r'(?:'
    # C/C++/Qt code patterns
    r'nullptr|NULL'
    r'|\bselect\b|\bxsl:|\bQLatin1String\b|\bQString\b'
    r'|\barg\(|\bget\(|\bset\(|\bcreateElement\b'
    r'|\.arg\(|\.Get\(|\.get\(|\.set\('
    r'|->|::|\bnew\b\s+\w+\b'
    r'|\bi18n\(|\bthis\)'
    r'|%\d'
    r'|\bdoc\.'
    r'|= info\.|= t\.'
    # DNG SDK / XMP fragments
    r'|\bNotEmpty\b|__FILE__|__LINE__'
    r'|\bkXMP_|\bdc:rights|\bx-default'
    r'|\bIO\.shrink'
    r'|\$Id[\s:]'
    # HTML/XML fragments in copyright values
    r'|</?(?:a|xsl)\b|"/>|</license>'
    r'|<a\s+href='
    # License text mistaken for holders
    r'|\bCreative\s+Commons\b'
    r'|\bPublic\s+Domain\b'
    r'|\blicensed?\s+under\b'
    r'|\bLicense:\s'
    r'|\bBY-SA\)'
    r'|creativecommons\.org'
    r'|\bNo\s+Warranty\b'
    # Bare URL as sole holder
    r'|^https?://'
    r')', re.IGNORECASE)

# Short fragments that are clearly not a person/org name. These are
# matched against the author portion AFTER stripping years and
# (C)/Copyright prefixes.
_NOT_A_HOLDER_RE = re.compile(
    r'(?:'
    r'^etc\.?\)?$'
    r'|^field\b|^symbol$|^lang=|^properties\b|^w\d+$'
    r'|^this$|^for the\b|^of the\b|^interest in'
    r'|^fields? from|usually$'
    r'|^=\s|^\(\*\)|^\)'
    # Bare single common-word fragments (not person/org names)
    r'|^(?:holder|schema|property|value|risk|All|reserved)$'
    r'|^(?:are aliased|for more|in several|related fields)'
    r'|^(?:to dc:|to the x-default|value to the)'
    r'|^(?:the combination|sufficient)'
    r'|^(?:is made available|no copyright)'
    r'|^property of\b'
    # Trailing word "Created" after an otherwise valid holder
    r'|\bCreated$'
    # Domain-only fragments from broken email splitting (e.g. "potsdam.de>")
    r'|^[\w.-]+\.\w{2,}>$'
    # Obfuscation-token orphans: "dot ca>", "do de>", "umu se>"
    r'|^(?:dot|do|umu)\s+\w+>$'
    # Translated UI / IPTC metadata strings
    r'|^(?:informatie|för bilden|na de\b|marks to|sobre a|ut de\b)'
    r'|^(?:date of|identity in|Exif and|where the|used when|to top of)'
    r'|^(?:digiKam$|digikam package$|kdegraphics package$|kipi-plugins package$)'
    # Short all-caps tokens (<=4 chars) or dotted acronyms (M.I.T.)
    r'|^[A-Z]{1,4}$'
    r'|^(?:[A-Z]\.){2,}$'
    # Strings with embedded > (orphan angle bracket from split emails)
    r'|^[^<]*\w>$'
    # ── Placeholder / template text from .po translations ──
    # Example names used in copyright documentation
    r'|\bJohn Doe\b|\bJane Doe\b|\bJose Fulano\b|\bJose Pires\b|\bJanez Novak\b'
    # Template variables and boilerplate
    r'|\bYear\b.*\b(?:Copyright|holder)\b'
    r'|\bCopyright\s+owner\b'
    r'|\bYEAR\b'
    r'|\bThis_file_is_part_of'
    # Fully parenthesized text: "(XMP is is always preferred)" etc.
    r'|^\(.*\)$'
    r')',
    re.IGNORECASE)

# Holder strings that look like a whole copyright notice was
# accidentally included (e.g. "Creative Commons Attribution-ShareAlike 2.5 Generic").
_LICENSE_NAME_AS_HOLDER_RE = re.compile(
    r'(?:'
    r'Attribution[-\s]ShareAlike'
    r'|CC0'
    r'|\bGPL\b|\bLGPL\b|\bAGPL\b|\bMIT\b'
    r'|\bApache\b.*[Ll]icense'
    r'|\b[Cc]ommons\b'
    r')',
    re.IGNORECASE)

def _is_plausible_copyright(s):
    """Return True if *s* looks like a real copyright holder string."""
    if not s or not s.strip():
        return False
    # At least 3 printable-ASCII characters required
    ascii_printable = sum(1 for c in s if ord(c) in _PRINTABLE_RANGE)
    if ascii_printable < 3:
        return False
    # Reject if more than 30 % of characters are outside printable ASCII
    ratio = ascii_printable / len(s)
    if ratio < 0.70:
        return False
    # Reject code fragments (C++, Qt, XSLT, format strings, etc.)
    if _CODE_FRAGMENT_RE.search(s):
        return False
    # Strip copyright prefix and years to get the author part, then
    # reject strings that clearly aren't a person or organisation.
    author = re.sub(r'^(?:copyright\s*)?(?:\(c\)|©)?\s*(?:copyright\s*)?\s*',
                    '', s, flags=re.IGNORECASE)
    author = re.sub(r'\b\d{4}\b', '', author)
    author = re.sub(r'^[\s,;-]+|[\s,;-]+$', '', author).strip()
    if not author:
        return False
    if len(author) < 2:
        return False
    if _NOT_A_HOLDER_RE.search(author):
        return False
    # Reject license names mistaken for holders
    if _LICENSE_NAME_AS_HOLDER_RE.search(author):
        # Allow if it looks like "Free Software Foundation" (org name
        # that happens to contain a license-adjacent word) but reject
        # things like "Creative Commons Attribution-ShareAlike 2.5 Generic"
        if not re.search(r'\b(?:Foundation|Incorporat|GmbH|Ltd|LLC|Corp)\b',
                         author, re.IGNORECASE):
            return False
    # Reject strings that are entirely non-name: no uppercase letter
    # and no CJK/Cyrillic — likely a code comment or stray phrase.
    # Exception: email-only entries like "<user@example.com>" are valid.
    if not re.search(r'[A-Z\u00C0-\u024F\u0400-\u04FF\u4E00-\u9FFF]', author):
        if not re.match(r'^<[\w.+-]+@[\w.-]+>$', author):
            return False
    return True

for _r in scan_results:
    _r["copyrights"] = [c for c in _r["copyrights"] if _is_plausible_copyright(c)]

total_files = len(scan_results)
print(f"  {total_files} files to analyze.", file=sys.stderr)

def progress(current, total, label="Analyzing"):
    """Print a progress update to stderr (overwriting current line)."""
    if total < 200:
        return  # not worth showing for small scans
    if current % 500 == 0 or current == total:
        pct = current * 100 // total
        sys.stderr.write(f"\r  {label}: {current}/{total} ({pct}%)")
        sys.stderr.flush()
        if current == total:
            sys.stderr.write("\n")
            sys.stderr.flush()

# ══════════════════════════════════════════════════════════════════════
# Desktop wallpaper metadata enrichment
# ══════════════════════════════════════════════════════════════════════
# KDE metadata.json and GNOME background-properties .xml files describe
# wallpaper images but are NOT themselves subject to the image copyright.
# Extract author/license info and apply it to the ACTUAL IMAGE FILES.

# Build path → scan_result lookup for fast enrichment
_path_to_result = {}
for _r in scan_results:
    _path_to_result[_r["path"].lstrip("./")] = _r

_enriched_count = 0

# ── KDE metadata.json enrichment ─────────────────────────────────────
for _r in scan_results:
    _fp = _r["path"].lstrip("./")
    if os.path.basename(_fp) != "metadata.json":
        continue
    try:
        with open(_fp, encoding="utf-8") as _mf:
            _meta = json.load(_mf)
    except (OSError, json.JSONDecodeError, UnicodeDecodeError):
        continue
    _kp = _meta.get("KPlugin", {})
    if not _kp:
        continue
    # Extract author holder strings
    _holders = []
    for _auth in _kp.get("Authors", []):
        _name = _auth.get("Name", "").strip()
        _email = _auth.get("Email", "").strip()
        if _name or _email:
            parts = []
            if _name:
                parts.append(_name)
            if _email:
                parts.append(f"<{_email}>")
            _holders.append(" ".join(parts))
    # Extract license
    _klic = _kp.get("License", "").strip()
    _dep5_lic = to_dep5(_klic) if _klic else None

    # Detect Unsplash-sourced images: if the metadata contains an
    # unsplash.com URL (in Website, Id, or anywhere in the JSON),
    # the actual license for the image is the Unsplash license,
    # regardless of what KPlugin.License says (it is often wrong
    # or generic for Unsplash-sourced wallpapers).
    _meta_str = json.dumps(_meta).lower()
    if "unsplash.com" in _meta_str:
        _dep5_lic = "Unsplash"

    if not _holders and not _dep5_lic:
        continue
    # Resolve symlinks in contents/images/ to find the actual image
    _wdir = str(Path(_fp).parent)
    _img_dir = os.path.join(_wdir, "contents", "images")
    if not os.path.isdir(_img_dir):
        continue
    _target_paths = set()
    for _link in os.listdir(_img_dir):
        _link_path = os.path.join(_img_dir, _link)
        if os.path.islink(_link_path):
            _real = os.path.relpath(os.path.realpath(_link_path))
            _target_paths.add(_real)
    # Enrich the actual image file entries
    for _img_path in _target_paths:
        _img_r = _path_to_result.get(_img_path)
        if not _img_r:
            continue
        for _h in _holders:
            if _h not in _img_r["copyrights"]:
                _img_r["copyrights"].append(_h)
        if _dep5_lic:
            if _img_r["licenses"] and _img_r["licenses"] != [_dep5_lic]:
                print(f"  KDE metadata override: {_img_path}: "
                      f"{', '.join(_img_r['licenses'])} → {_dep5_lic}",
                      file=sys.stderr)
            _img_r["licenses"] = [_dep5_lic]
        _enriched_count += 1

# ── SVG RDF/XML embedded metadata enrichment ─────────────────────────
# SVG files may embed Creative Commons license info and author details
# in <metadata> via RDF.  When present this is the *authoritative*
# declaration for the file and overrides scancode's heuristic detection
# (which is known to misidentify CC license versions).
_CC_URL_RE = re.compile(
    r'https?://creativecommons\.org/licenses/'
    r'([-a-z]+)/([\d.]+)',
    re.IGNORECASE,
)
_PUBLIC_DOMAIN_URLS = {
    "http://creativecommons.org/publicdomain/zero/1.0/",
    "https://creativecommons.org/publicdomain/zero/1.0/",
    "http://creativecommons.org/publicdomain/mark/1.0/",
    "https://creativecommons.org/publicdomain/mark/1.0/",
}

def _cc_url_to_dep5(url):
    """Convert a Creative Commons license URL to a DEP-5 identifier."""
    url_lower = url.lower().rstrip("/") + "/"
    if url_lower in _PUBLIC_DOMAIN_URLS:
        if "zero" in url_lower:
            return "CC0-1.0"
        return "Public-Domain"
    m = _CC_URL_RE.search(url)
    if m:
        kind = m.group(1).upper()   # e.g. "by-sa" → "BY-SA"
        ver = m.group(2)            # e.g. "4.0"
        return f"CC-{kind}-{ver}"
    return None

for _r in scan_results:
    _fp = _r["path"].lstrip("./")
    if not _fp.lower().endswith(".svg"):
        continue
    try:
        _svg_tree = ET.parse(_fp)
    except (OSError, ET.ParseError):
        continue
    _svg_root = _svg_tree.getroot()

    _svg_lic = None
    _svg_creator = None

    # Walk all elements looking for cc:license and dc:creator
    _CC_NS = "http://creativecommons.org/ns#"
    _DC_NS = "http://purl.org/dc/elements/1.1/"
    _RDF_NS = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"

    for _el in _svg_root.iter():
        _tag = _el.tag
        # cc:license — the rdf:resource attribute has the license URL
        if _tag == f"{{{_CC_NS}}}license" and not _svg_lic:
            _lic_url = _el.get(f"{{{_RDF_NS}}}resource", "")
            if _lic_url:
                _svg_lic = _cc_url_to_dep5(_lic_url)
        # cc:License — rdf:about attribute has license URL (fallback)
        elif _tag == f"{{{_CC_NS}}}License" and not _svg_lic:
            _lic_url = _el.get(f"{{{_RDF_NS}}}about", "")
            if _lic_url:
                _svg_lic = _cc_url_to_dep5(_lic_url)
        # dc:creator > cc:Agent > dc:title
        elif _tag == f"{{{_DC_NS}}}creator" and not _svg_creator:
            _agent = _el.find(f"{{{_CC_NS}}}Agent")
            if _agent is not None:
                _title = _agent.find(f"{{{_DC_NS}}}title")
                if _title is not None and _title.text and _title.text.strip():
                    _svg_creator = _title.text.strip()

    if not _svg_lic and not _svg_creator:
        continue

    # License: OVERRIDE scancode's detection — the file's embedded
    # metadata is authoritative for its own license.
    if _svg_lic:
        _old_lics = _r["licenses"]
        _new_lic = to_dep5(_svg_lic)
        if _old_lics != [_new_lic]:
            _r["licenses"] = [_new_lic]
            if _old_lics:
                print(f"  SVG metadata override: {_fp}: "
                      f"{', '.join(_old_lics)} → {_new_lic}",
                      file=sys.stderr)
            _enriched_count += 1

    # Copyright holder: add if not already present
    if _svg_creator and _svg_creator not in _r["copyrights"]:
        _r["copyrights"].append(_svg_creator)
        _enriched_count += 1

# ── XMP metadata enrichment for raster images ────────────────────────
# JPEG, PNG, TIFF, WebP and other image formats commonly embed XMP
# (Extensible Metadata Platform) packets — the same RDF/XML used in
# SVGs — containing dc:creator, cc:license, dc:rights, and date info.
# This extracts that XML via byte-string search (no external library
# needed) and reuses _cc_url_to_dep5() for license normalisation.
_XMP_IMAGE_EXTS = frozenset({
    ".png", ".jpg", ".jpeg", ".tif", ".tiff",
    ".webp", ".gif", ".bmp", ".ico",
})
_XMP_RIGHTS_NS = "http://ns.adobe.com/xap/1.0/rights/"
_XMP_NS = "http://ns.adobe.com/xap/1.0/"
_PHOTOSHOP_NS = "http://ns.adobe.com/photoshop/1.0/"

def _extract_xmp_xml(filepath, max_read=2 * 1024 * 1024):
    """Extract the XMP XML packet from a binary file.
    Returns the XML as a string, or None if no XMP is found.
    Reads at most *max_read* bytes to avoid loading huge files."""
    try:
        with open(filepath, "rb") as fh:
            data = fh.read(max_read)
    except OSError:
        return None
    # Look for xpacket or xmpmeta start markers
    start = data.find(b"<x:xmpmeta")
    if start < 0:
        start = data.find(b"<?xpacket")
    if start < 0:
        return None
    # Find matching end
    end = data.find(b"</x:xmpmeta>", start)
    if end >= 0:
        end += len(b"</x:xmpmeta>")
    else:
        end = data.find(b"<?xpacket end", start)
        if end >= 0:
            end = data.find(b"?>", end)
            if end >= 0:
                end += 2
    if end < 0:
        return None
    try:
        return data[start:end].decode("utf-8", errors="replace")
    except Exception:
        return None

for _r in scan_results:
    _fp = _r["path"].lstrip("./")
    _ext = os.path.splitext(_fp)[1].lower()
    if _ext not in _XMP_IMAGE_EXTS:
        continue
    _xmp_xml = _extract_xmp_xml(_fp)
    if not _xmp_xml:
        continue
    try:
        _xmp_root = ET.fromstring(_xmp_xml)
    except ET.ParseError:
        continue

    _xmp_lic = None
    _xmp_creator = None
    _xmp_rights = None
    _xmp_year = None

    _CC_NS = "http://creativecommons.org/ns#"
    _DC_NS = "http://purl.org/dc/elements/1.1/"
    _RDF_NS = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"

    for _el in _xmp_root.iter():
        _tag = _el.tag

        # cc:license — rdf:resource attribute holds the URL
        if _tag == f"{{{_CC_NS}}}license" and not _xmp_lic:
            _lic_url = _el.get(f"{{{_RDF_NS}}}resource", "")
            if _lic_url:
                _xmp_lic = _cc_url_to_dep5(_lic_url)
        elif _tag == f"{{{_CC_NS}}}License" and not _xmp_lic:
            _lic_url = _el.get(f"{{{_RDF_NS}}}about", "")
            if _lic_url:
                _xmp_lic = _cc_url_to_dep5(_lic_url)

        # xmpRights:WebStatement — fallback license URL
        elif _tag == f"{{{_XMP_RIGHTS_NS}}}WebStatement" and not _xmp_lic:
            _ws = (_el.text or "").strip()
            if _ws:
                _xmp_lic = _cc_url_to_dep5(_ws)

        # dc:creator > rdf:Seq > rdf:li  or  dc:creator (flat text)
        elif _tag == f"{{{_DC_NS}}}creator" and not _xmp_creator:
            _seq = _el.find(f"{{{_RDF_NS}}}Seq")
            if _seq is not None:
                _li = _seq.find(f"{{{_RDF_NS}}}li")
                if _li is not None and _li.text and _li.text.strip():
                    _xmp_creator = _li.text.strip()
            elif _el.text and _el.text.strip():
                _xmp_creator = _el.text.strip()

        # photoshop:Credit — alternative author source
        elif _tag == f"{{{_PHOTOSHOP_NS}}}Credit" and not _xmp_creator:
            _cr = (_el.text or "").strip()
            if _cr:
                _xmp_creator = _cr

        # dc:rights > rdf:Alt > rdf:li — copyright string
        elif _tag == f"{{{_DC_NS}}}rights" and not _xmp_rights:
            _alt = _el.find(f"{{{_RDF_NS}}}Alt")
            if _alt is not None:
                _li = _alt.find(f"{{{_RDF_NS}}}li")
                if _li is not None and _li.text and _li.text.strip():
                    _xmp_rights = _li.text.strip()

        # xmp:CreateDate — extract year
        elif _tag == f"{{{_XMP_NS}}}CreateDate" and not _xmp_year:
            _dt = (_el.text or "").strip()
            _ym = re.match(r'(\d{4})', _dt)
            if _ym:
                _xmp_year = _ym.group(1)

    if not _xmp_lic and not _xmp_creator and not _xmp_rights:
        continue

    # License: override scanner's detection when XMP is present
    if _xmp_lic:
        _old_lics = _r["licenses"]
        _new_lic = to_dep5(_xmp_lic)
        if _old_lics != [_new_lic]:
            _r["licenses"] = [_new_lic]
            if _old_lics:
                print(f"  XMP metadata override: {_fp}: "
                      f"{', '.join(_old_lics)} → {_new_lic}",
                      file=sys.stderr)
            _enriched_count += 1

    # Copyright holder: prefer dc:creator, fall back to dc:rights text
    _holder = _xmp_creator
    if not _holder and _xmp_rights:
        # Parse "Copyright 2024 Some Author" to extract the author part
        _stripped = re.sub(
            r'^(?:copyright|\(c\)|©)\s*\d[\d,\s-]*', '',
            _xmp_rights, flags=re.IGNORECASE,
        ).strip()
        if _stripped:
            _holder = _stripped
    if _holder:
        # Prepend year if we have one and it's not already in copyrights
        if _xmp_year and not any(_xmp_year in c for c in _r["copyrights"]):
            _holder_with_year = f"{_xmp_year} {_holder}"
        else:
            _holder_with_year = _holder
        if _holder_with_year not in _r["copyrights"] and _holder not in _r["copyrights"]:
            _r["copyrights"].append(_holder_with_year)
            _enriched_count += 1

# ── GNOME background-properties XML enrichment ───────────────────────
for _xml_path in globmod.glob("usr/share/gnome-background-properties/*.xml"):
    try:
        _tree = ET.parse(_xml_path)
    except (OSError, ET.ParseError):
        continue
    for _wp in _tree.findall(".//wallpaper"):
        _name_el = _wp.find("name")
        _fn_el = _wp.find("filename")
        if _name_el is None or _fn_el is None:
            continue
        _name_text = (_name_el.text or "").strip()
        _fn_text = (_fn_el.text or "").strip()
        if not _name_text or not _fn_text:
            continue
        # Extract artist from "Title by Artist Name" format
        _by_match = re.search(r'\s+by\s+(.+)$', _name_text, re.IGNORECASE)
        if not _by_match:
            continue
        _artist = _by_match.group(1).strip()
        if not _artist:
            continue
        # Map absolute filename to relative path
        _img_rel = _fn_text.lstrip("/")
        _img_r = _path_to_result.get(_img_rel)
        if not _img_r:
            continue
        if _artist not in _img_r["copyrights"]:
            _img_r["copyrights"].append(_artist)
            _enriched_count += 1
        # Detect Unsplash-sourced images: check for unsplash.com in any
        # child element (e.g. <source>) or the wallpaper XML text content
        if not _img_r["licenses"]:
            _wp_text = ET.tostring(_wp, encoding="unicode", method="text")
            _wp_xml = ET.tostring(_wp, encoding="unicode")
            if "unsplash.com" in _wp_text or "unsplash.com" in _wp_xml:
                _img_r["licenses"] = ["Unsplash"]

if _enriched_count:
    print(f"  {_enriched_count} image(s) enriched from wallpaper metadata.",
          file=sys.stderr)

# ══════════════════════════════════════════════════════════════════════
# Directory license/copyright inheritance
# ══════════════════════════════════════════════════════════════════════
# For quilt packages only: files with no detected license/copyright
# inherit from the dominant license in their directory, but ONLY when
# a COPYING or LICENSE file exists in the same or parent directory
# (indicating a subproject).  Native packages skip inheritance
# entirely — files with no detection fall through to Files: *.

_LICENSE_FILE_NAMES = {
    "copying", "copying.txt", "copying.md",
    "license", "license.txt", "license.md", "licence", "licence.txt",
    "copying.lgpl", "copying.gpl", "copying.bsd", "copying.mit",
    "copying.lib", "unlicense", "unlicense.txt",
    "copyright", "copyright.txt",
}

_dir_lic_counts = defaultdict(lambda: defaultdict(int))
_dir_copyrights = defaultdict(set)
# Track which directories contain a COPYING/LICENSE file
_dirs_with_license_file = set()
for _r in scan_results:
    _fp = _r["path"].lstrip("./")
    _d = str(Path(_fp).parent)
    # Check if this file is a license/copying file
    if Path(_fp).name.lower() in _LICENSE_FILE_NAMES:
        _dirs_with_license_file.add(_d)
    if not _r["licenses"]:
        continue
    _lkey = tuple(sorted(set(to_dep5(l) for l in _r["licenses"])))
    _dir_lic_counts[_d][_lkey] += 1
    for _c in _r["copyrights"]:
        _dir_copyrights[_d].add(_c)

def _dir_has_license_file(d):
    """Return True if directory *d* itself contains a COPYING/LICENSE file.

    The root directory's LICENSE describes the overall project license
    (already captured by Files: *), so we do NOT check parent directories.
    Files in subdirectories without their own LICENSE fall through to the
    catch-all stanza rather than inheriting from directory peers."""
    return d in _dirs_with_license_file and d != "."

def dir_dominant_license(d):
    """Return the most common license tuple for directory *d*.

    Only uses licenses detected in the SAME directory.
    Ties are broken alphabetically for determinism."""
    if d in _dir_lic_counts and _dir_lic_counts[d]:
        return max(_dir_lic_counts[d],
                   key=lambda k: (_dir_lic_counts[d][k], k))
    return None

def dir_copyrights(d):
    """Return copyright holders from directory *d* (same-dir only)."""
    if d in _dir_copyrights and _dir_copyrights[d]:
        return sorted(_dir_copyrights[d])
    return []

_inherited_count = 0
if is_native:
    print("  Native package — skipping directory license inheritance.",
          file=sys.stderr)
else:
    for _r in scan_results:
        if _r["licenses"]:
            continue
        _fp = _r["path"].lstrip("./")
        _d = str(Path(_fp).parent)
        if not _dir_has_license_file(_d):
            continue
        _inh = dir_dominant_license(_d)
        if _inh:
            _r["licenses"] = list(_inh)
            _r["_inherited"] = True
            _inherited_count += 1
        if not _r["copyrights"]:
            _icops = dir_copyrights(_d)
            if _icops:
                _r["copyrights"] = _icops

if _inherited_count:
    print(f"  {_inherited_count} file(s) inherited license from directory context.",
          file=sys.stderr)

# ══════════════════════════════════════════════════════════════════════
# Parse existing debian/copyright
# ══════════════════════════════════════════════════════════════════════
stanzas = []  # [(glob, license_id)]
stanza_copyrights = {}  # {glob: [copyright_holder_strings]}
if copyright_file and os.path.isfile(copyright_file):
    with open(copyright_file, encoding="utf-8") as f:
        copyright_text = f.read()
    for para in copyright_text.split("\n\n"):
        files = []
        lic = None
        cops = []
        current_field = None  # Track which field continuation lines belong to
        for line in para.split("\n"):
            if line.startswith("Files:"):
                rest = line[6:].strip()
                if rest:
                    files.extend(rest.split())
                current_field = "files"
            elif line.startswith("Copyright:"):
                cop_val = line[10:].strip()
                if cop_val:
                    cops.append(cop_val)
                current_field = "copyright"
            elif line.startswith("License:"):
                lic = line[8:].strip().split()[0] if line[8:].strip() else None
                current_field = "license"
            elif current_field == "files" and line.startswith(" ") and not line.startswith(" ."):
                files.extend(line.strip().split())
            elif current_field == "copyright" and line.startswith(" ") and line.strip():
                # Continuation line for Copyright (additional holders)
                cops.append(line.strip())
        if files and lic:
            # Filter out scanner false-positives (code fragments, etc.)
            cops = [c for c in cops if _is_plausible_copyright(c)]
            for f in files:
                stanzas.append((f, lic))
                stanza_copyrights[f] = cops

# Also parse standalone License text blocks
license_blocks = {}  # {license_name: text_body}
if copyright_file and os.path.isfile(copyright_file):
    for para in copyright_text.split("\n\n"):
        lines = para.split("\n")
        if not lines:
            continue
        first = lines[0]
        has_files = any(l.startswith("Files:") for l in lines)
        if first.startswith("License:") and not has_files:
            lic_name = first[8:].strip().split()[0] if first[8:].strip() else None
            if lic_name:
                body_lines = []
                for l in lines[1:]:
                    # DEP-5: continuation lines start with space or " ."
                    if l.startswith(" ") or l == " .":
                        body_lines.append(l[1:] if l.startswith(" ") else "")
                    elif l.strip() == ".":
                        body_lines.append("")
                license_blocks[lic_name] = "\n".join(body_lines)

# ══════════════════════════════════════════════════════════════════════
# License text completeness checking
# ══════════════════════════════════════════════════════════════════════

# Licenses available in /usr/share/common-licenses/ on this system.
# Under snap strict confinement the host path is inaccessible, so fall back to
# the copy bundled inside the snap via the base-files stage-package.
SYSTEM_COMMON_LICENSES = set()
for _cl_dir in (
    "/usr/share/common-licenses",
    os.path.join(os.environ.get("SNAP", ""), "usr/share/common-licenses"),
):
    try:
        entries = os.listdir(_cl_dir)
        if entries:
            for entry in entries:
                SYSTEM_COMMON_LICENSES.add(entry)
            break
    except OSError:
        pass

def check_license_completeness():
    """Check each referenced license has a complete text block.

    Returns list of (license_name, severity, message) tuples.
    severity: 'error', 'warning', 'info'
    """
    issues = []
    # Collect all licenses referenced by file stanzas
    referenced = set(lic for _, lic in stanzas)
    if not referenced:
        return issues

    for lic in sorted(referenced):
        slug = lic.rstrip("+")

        # Check if it has a standalone License block
        if lic not in license_blocks:
            issues.append((lic, "error",
                f"No standalone License block found for '{lic}'"))
            continue

        body = license_blocks[lic]
        body_stripped = body.strip()
        body_lines = [l for l in body_stripped.split("\n") if l.strip()]
        text_len = len(body_stripped)

        # Check for FIXME/TODO placeholders
        if re.search(r'FIXME|TODO|PLACEHOLDER', body_stripped, re.IGNORECASE):
            issues.append((lic, "error",
                f"License '{lic}' contains FIXME/TODO placeholder"))
            continue

        # Check if it references /usr/share/common-licenses/
        cl_ref = re.search(r'/usr/share/common-licenses/([\w.+-]+)', body_stripped)
        if cl_ref:
            ref_name = cl_ref.group(1)
            if ref_name not in SYSTEM_COMMON_LICENSES and slug not in SYSTEM_COMMON_LICENSES:
                issues.append((lic, "error",
                    f"License '{lic}' references /usr/share/common-licenses/{ref_name} "
                    f"but that file does not exist on this system"))
            else:
                # Valid common-licenses reference, that's sufficient
                continue

        # ── Creative Commons specific checks ──
        # lintian specifically checks for incomplete CC licenses
        if re.match(r'^CC-BY', lic, re.IGNORECASE):
            # CC licenses are verbose — full text is typically 10KB+
            # A summary/abstract is usually < 2KB
            if text_len < 3000:
                issues.append((lic, "warning",
                    f"License '{lic}' appears to be a summary, not the full legal text "
                    f"({text_len} chars, full text is typically 10000+). "
                    f"lintian will flag this as 'incomplete-creative-commons-license'"))
                continue
            # Check for key full-text markers
            if not re.search(r'Section\s+[1-8]|BY EXERCISING|Creative Commons Corporation',
                             body_stripped):
                issues.append((lic, "warning",
                    f"License '{lic}' may not be the full legal text "
                    f"(missing expected legal code markers)"))
                continue

        # ── General completeness check ──
        # A real license text should have substantial content
        # Most open source licenses are at least 500 chars
        if text_len < 50:
            issues.append((lic, "error",
                f"License '{lic}' text block is nearly empty ({text_len} chars)"))
        elif text_len < 200 and slug not in SYSTEM_COMMON_LICENSES:
            issues.append((lic, "warning",
                f"License '{lic}' text block is very short ({text_len} chars) "
                f"and not in /usr/share/common-licenses/"))

    return issues

def check_coverage():
    """Check stanza coverage vs actual source files.

    Returns:
        uncovered: list of (filepath, detected_licenses) not matched by any stanza
        stale: list of (glob_pattern, license) that match no scanned file
    """
    # All scanned file paths (stripped of ./)
    scanned_paths = set(r["path"].lstrip("./") for r in scan_results)

    # 1) Files not covered by any stanza glob
    uncovered = []
    for r in scan_results:
        fp = r["path"].lstrip("./")
        matched = False
        for glob_pat, _ in stanzas:
            if fnmatch.fnmatch(fp, glob_pat) or glob_pat == "*":
                matched = True
                break
        if not matched:
            lics = [to_dep5(l) for l in r["licenses"]] if r["licenses"] else []
            uncovered.append((fp, lics))

    # 2) Stanza globs that match no scanned file (stale entries)
    stale = []
    seen_globs = set()
    for glob_pat, lic in stanzas:
        if glob_pat == "*":  # catch-all always "matches"
            continue
        if glob_pat in seen_globs:
            continue
        seen_globs.add(glob_pat)
        has_match = any(fnmatch.fnmatch(sp, glob_pat) for sp in scanned_paths)
        if not has_match:
            stale.append((glob_pat, lic))

    return uncovered, stale

def check_license_compatibility(declared_licenses):
    """Check for known license incompatibilities among declared licenses.

    Returns list of human-readable issue strings.
    """
    issues = []
    norms = {lic: norm_cmp(lic) for lic in declared_licenses}

    has_gpl2_only = any(n == "gpl-2" and not lic.endswith("+") for lic, n in norms.items())
    has_gpl2plus = any(n == "gpl-2+" or (n == "gpl-2" and lic.endswith("+")) for lic, n in norms.items())
    has_gpl3_only = any(n == "gpl-3" and not lic.endswith("+") for lic, n in norms.items())
    has_apache = any("apache" in n for n in norms.values())
    has_lgpl3 = any(n in ("lgpl-3", "lgpl-3+") for n in norms.values())
    has_cddl = any("cddl" in n for n in norms.values())
    has_mpl1 = any(n == "mpl-1.1" for n in norms.values())

    # Apache-2.0 vs GPL-2-only
    if has_apache and has_gpl2_only and not has_gpl2plus:
        issues.append(
            "Apache-2.0 is INCOMPATIBLE with GPL-2 (without 'or later'). "
            "This is a real license conflict that must be resolved.")
    elif has_apache and has_gpl2plus:
        issues.append(
            "NOTE: Apache-2.0 + GPL-2+ => effective GPL-3+. "
            "This is compatible for Debian.")

    # GPL-2-only + GPL-3-only (cannot satisfy both simultaneously)
    if has_gpl2_only and has_gpl3_only:
        issues.append(
            "GPL-2 (only) and GPL-3 (only) code cannot be combined — "
            "no single GPL version satisfies both.")

    # GPL-2-only + LGPL-3+ (LGPL-3 converts to GPL-3, incompatible with GPL-2-only)
    if has_gpl2_only and has_lgpl3 and not has_gpl2plus:
        issues.append(
            "LGPL-3 converts to GPL-3 for combined works, "
            "which is incompatible with GPL-2 (only).")

    # CDDL + GPL (any version, they are fundamentally incompatible)
    gpl_any = any("gpl" in n and "lgpl" not in n for n in norms.values())
    if has_cddl and gpl_any:
        issues.append(
            "CDDL and GPL are incompatible copyleft licenses. "
            "They cannot be combined in a single work.")

    # MPL-1.1 + GPL (MPL-1.1 is not GPL-compatible; MPL-2.0 is)
    if has_mpl1 and gpl_any:
        issues.append(
            "MPL-1.1 is not compatible with the GPL. "
            "Consider upgrading to MPL-2.0 which has a GPL compatibility clause.")

    # EPL-1.0 + GPL (Eclipse Public License 1.0 is GPL-incompatible)
    has_epl1 = any(n == "epl-1" for n in norms.values())
    has_epl2 = any(n == "epl-2" for n in norms.values())
    if has_epl1 and gpl_any:
        issues.append(
            "EPL-1.0 is INCOMPATIBLE with the GPL. "
            "EPL-2.0 with secondary license notice may allow GPL combination.")
    # EPL-2.0 note (compatible only with secondary license exception)
    if has_epl2 and gpl_any:
        issues.append(
            "NOTE: EPL-2.0 is GPL-compatible ONLY if the code specifies a "
            "'Secondary License' (GPL-2.0). Check EPL-2.0 headers for this clause.")

    # OSL-3.0 + GPL (Open Software License — copyleft, GPL-incompatible)
    has_osl = any("osl" in n for n in norms.values())
    if has_osl and gpl_any:
        issues.append(
            "OSL (Open Software License) is INCOMPATIBLE with the GPL. "
            "Both are copyleft but mutually exclusive.")

    # OpenSSL + GPL (advertising clause makes it GPL-incompatible)
    has_openssl = any("openssl" in n for n in norms.values())
    if has_openssl and gpl_any:
        issues.append(
            "OpenSSL license is INCOMPATIBLE with the GPL due to its "
            "advertising clause. Use a GPL exception or replace OpenSSL.")

    # BSD-4-Clause + GPL (advertising clause makes it GPL-incompatible)
    has_bsd4 = any(n == "bsd-4-clause" for n in norms.values())
    if has_bsd4 and gpl_any:
        issues.append(
            "BSD-4-Clause (original BSD) is INCOMPATIBLE with the GPL "
            "due to its advertising clause. Use BSD-3-Clause instead.")

    # QPL-1.0 + GPL (Qt Public License — cannot link with GPL code)
    has_qpl = any(n == "qpl-1" for n in norms.values())
    if has_qpl and gpl_any:
        issues.append(
            "QPL-1.0 is INCOMPATIBLE with the GPL for linked/combined works. "
            "QPL-licensed code cannot be combined with GPL code in a single program.")

    return issues

def check_dep5_format():
    """Validate DEP-5 structural/format compliance of debian/copyright.

    Returns list of (line_number, severity, message, fix_tag) tuples.
    fix_tag is a string identifying the auto-fix category, or None.
    """
    issues = []
    if not copyright_file or not os.path.isfile(copyright_file):
        return issues

    with open(copyright_file, encoding="utf-8") as f:
        raw = f.read()
    lines = raw.split("\n")

    # ── 1. Header paragraph: must start with Format: ─────────────
    paragraphs = raw.split("\n\n")
    if paragraphs:
        header = paragraphs[0]
        header_lines = header.split("\n")
        if not header_lines or not header_lines[0].startswith("Format:"):
            issues.append((1, "error",
                "First line must be a Format: field "
                "(e.g. Format: https://www.debian.org/doc/packaging-manuals/"
                "copyright-format/1.0/)", "missing-format"))
        else:
            fmt_url = header_lines[0][7:].strip()
            if "debian.org" not in fmt_url:
                issues.append((1, "warning",
                    f"Format URL looks non-standard: {fmt_url}", None))

        # ── 2. Recommended header fields ─────────────────────────
        hdr_fields = set()
        for hl in header_lines:
            m = re.match(r'^([A-Z][\w-]+):', hl)
            if m:
                hdr_fields.add(m.group(1))
        for rec in ("Upstream-Name", "Upstream-Contact", "Source"):
            if rec not in hdr_fields:
                issues.append((1, "warning",
                    f"Header paragraph missing recommended field: {rec}",
                    None))

    # ── 3. Multiple consecutive blank lines ──────────────────────
    for i, line in enumerate(lines):
        if i > 0 and line == "" and lines[i - 1] == "":
            issues.append((i + 1, "warning",
                "Multiple consecutive blank lines "
                "(paragraphs should be separated by exactly one blank line)",
                "multi-blank"))

    # ── 4. Trailing whitespace ───────────────────────────────────
    for i, line in enumerate(lines):
        if line != line.rstrip():
            issues.append((i + 1, "warning",
                f"Trailing whitespace on line {i + 1}", "trailing-ws"))

    # ── 5. Tab indentation in continuation lines ─────────────────
    for i, line in enumerate(lines):
        if line.startswith("\t"):
            issues.append((i + 1, "error",
                f"Tab indentation on line {i + 1} "
                "(DEP-5 continuation lines must use spaces, not tabs)",
                "tab-indent"))

    # ── 6. Blank continuation lines must be " ." ─────────────────
    in_block = False
    for i, line in enumerate(lines):
        if re.match(r'^[A-Z][\w-]+:', line):
            in_block = True
        elif line == "":
            in_block = False
        elif in_block and line.rstrip() == "" and line != "":
            # Line is all whitespace but not empty — should be " ."
            issues.append((i + 1, "error",
                f"Blank continuation line {i + 1} must be ' .' "
                "(space followed by period), not just whitespace",
                "blank-continuation"))

    # ── 7. File stanzas: required fields ─────────────────────────
    para_line = 1
    for para in paragraphs:
        plines = para.split("\n")
        fields = []
        for pl in plines:
            m = re.match(r'^([A-Z][\w-]+):', pl)
            if m:
                fields.append(m.group(1))

        has_files = "Files" in fields
        has_copyright = "Copyright" in fields
        has_license = "License" in fields

        if has_files:
            if not has_copyright:
                issues.append((para_line, "error",
                    f"Files stanza at line {para_line} missing required "
                    "Copyright: field", None))
            if not has_license:
                issues.append((para_line, "error",
                    f"Files stanza at line {para_line} missing required "
                    "License: field", None))
            # Check field ordering: Files before Copyright before License
            fi = fields.index("Files") if "Files" in fields else -1
            ci = fields.index("Copyright") if "Copyright" in fields else -1
            li = fields.index("License") if "License" in fields else -1
            if ci >= 0 and fi >= 0 and ci < fi:
                issues.append((para_line, "warning",
                    f"Files stanza at line {para_line}: Copyright: should "
                    "come after Files:", "field-order"))
            if li >= 0 and ci >= 0 and li < ci:
                issues.append((para_line, "warning",
                    f"Files stanza at line {para_line}: License: should "
                    "come after Copyright:", "field-order"))

        # Standalone License block must have body
        if has_license and not has_files and not plines[0].startswith("Format:"):
            body_lines = [l for l in plines[1:] if l.startswith(" ") or l == " ."]
            if not body_lines:
                lic_name = plines[0][8:].strip().split()[0] if plines[0][8:].strip() else "?"
                issues.append((para_line, "error",
                    f"Standalone License block '{lic_name}' at line "
                    f"{para_line} has no body text", None))

        # ── 8. Unrecognized fields ───────────────────────────────
        KNOWN_FIELDS = {
            "Format", "Upstream-Name", "Upstream-Contact", "Source",
            "Disclaimer", "Comment", "License", "Copyright", "Files",
            "Files-Excluded", "Files-Excluded-DFSG",
        }
        for pl in plines:
            m = re.match(r'^([A-Z][\w-]+):', pl)
            if m and m.group(1) not in KNOWN_FIELDS:
                lnum = para_line + plines.index(pl)
                issues.append((lnum, "warning",
                    f"Unrecognized field '{m.group(1)}' at line {lnum}",
                    None))

        para_line += len(plines) + 1  # +1 for blank separator

    # ── 9. Catch-all stanza ──────────────────────────────────────
    has_catchall = any(g == "*" for g, _ in stanzas)
    if not has_catchall and stanzas:
        issues.append((0, "warning",
            "No catch-all 'Files: *' stanza found (recommended)", None))

    # ── 10. Duplicate file globs ─────────────────────────────────
    glob_counts = defaultdict(int)
    for g, _ in stanzas:
        glob_counts[g] += 1
    for g, count in glob_counts.items():
        if count > 1:
            issues.append((0, "warning",
                f"File glob '{g}' appears in {count} stanzas "
                "(later stanzas override earlier ones)", None))

    # ── 11. File does not end with newline ────────────────────────
    if raw and not raw.endswith("\n"):
        issues.append((len(lines), "warning",
            "File does not end with a newline", "missing-newline"))

    return issues

def fix_dep5_format(text):
    """Apply automatic DEP-5 format fixes to the copyright text.

    Returns (fixed_text, list_of_fix_descriptions).
    """
    fixes = []

    # Fix trailing whitespace
    new_lines = []
    trailing_fixed = 0
    for line in text.split("\n"):
        stripped = line.rstrip()
        if stripped != line:
            trailing_fixed += 1
        new_lines.append(stripped)
    if trailing_fixed > 0:
        fixes.append(f"Removed trailing whitespace from {trailing_fixed} line(s)")
    text = "\n".join(new_lines)

    # Fix multiple consecutive blank lines
    old_len = len(text)
    while "\n\n\n" in text:
        text = text.replace("\n\n\n", "\n\n")
    if len(text) != old_len:
        fixes.append("Collapsed multiple consecutive blank lines")

    # Fix tab indentation → spaces (each leading tab → one space)
    tab_count = 0
    new_lines = []
    for line in text.split("\n"):
        if line.startswith("\t"):
            n_tabs = len(line) - len(line.lstrip("\t"))
            line = " " * n_tabs + line.lstrip("\t")
            tab_count += 1
        new_lines.append(line)
    if tab_count > 0:
        fixes.append(f"Replaced tab indentation with spaces on {tab_count} line(s)")
    text = "\n".join(new_lines)

    # Fix blank continuation lines (whitespace-only → " .")
    in_block = False
    new_lines = []
    blank_cont_fixed = 0
    for line in text.split("\n"):
        if re.match(r'^[A-Z][\w-]+:', line):
            in_block = True
            new_lines.append(line)
        elif line == "":
            in_block = False
            new_lines.append(line)
        elif in_block and line.rstrip() == "" and line != "":
            new_lines.append(" .")
            blank_cont_fixed += 1
        else:
            new_lines.append(line)
    if blank_cont_fixed > 0:
        fixes.append(f"Fixed {blank_cont_fixed} blank continuation line(s) → ' .'")
    text = "\n".join(new_lines)

    # Ensure file ends with newline
    if text and not text.endswith("\n"):
        text += "\n"
        fixes.append("Added missing trailing newline")

    return text, fixes

def get_declared_license(filepath):
    """Return the license declared for filepath using DEP-5 last-match-wins rule."""
    fp = filepath.lstrip("./")
    best = None
    # DEP-5 spec: when multiple stanzas match, the last one wins
    for glob, lic in stanzas:
        if fnmatch.fnmatch(fp, glob) or glob == "*":
            best = lic
    return best

def get_declared_copyrights(filepath):
    """Return the copyright holders declared for filepath (last-match-wins)."""
    fp = filepath.lstrip("./")
    best = []
    for glob, lic in stanzas:
        if fnmatch.fnmatch(fp, glob) or glob == "*":
            best = stanza_copyrights.get(glob, [])
    return best

# Corporate suffixes that should never start a new author entry
# when preceded by a comma (e.g. "Company, Inc.").
_CORP_SUFFIXES_RE = re.compile(
    r'^(?:Inc|Ltd|LLC|LLP|GmbH|AG|Co|Corp|Pty|SA|SAS|BV|NV'
    r'|Srl|SpA|KG|OHG|AB|AS|Oy|Bhd|Pte|SE|PLC|plc)\.?(?:\s|$)',
    re.IGNORECASE)

def _deobfuscate_email(s):
    """Convert obfuscated emails to real ones and normalise brackets.

    'alice at example dot com'       → 'alice@example.com'
    '(user@example.com)'             → '<user@example.com>'
    '(user at example dot com)'      → '<user@example.com>'
    '(bob dot smith@example.org)'    → '<bob.smith@example.org>'
    'fabian at ritter dash vogt dot de' → 'fabian@ritter-vogt.de'
    'mario dot frank at uni minus potsdam dot de'
                                       → 'mario.frank@uni-potsdam.de'
    'dmgerman at uvic doooot ca'     → 'dmgerman@uvic.ca'
    """
    # First normalize variant obfuscation tokens:
    #   'dash' / 'minus' → '-', 'doooot'/'dooot' etc. → 'dot',
    #   'underscore' / 'uderscore' → '_'
    s = re.sub(r'\s+(dash|minus)\s+', '-', s)
    s = re.sub(r'\bdo{3,}t\b', 'dot', s)  # doooot, dooot, etc.
    s = re.sub(r'\s+u?n?derscore\s+', '_', s)  # underscore, uderscore
    # Fix "name_<user@domain>" → "<name_user@domain>" (underscore token
    # was between the name part and a partially-deobfuscated email).
    # Also handles "<name_<user@domain>" (leading < from scanner).
    s = re.sub(r'<?(\w+)_<(\w+@[\w.-]+>)', r'<\1_\2', s)
    # Deobfuscate "word at word dot word" patterns
    def _deob(m):
        return m.group(0).replace(' at ', '@').replace(' dot ', '.')
    s = re.sub(r'\b\w[\w.+-]* at \w[\w. -]* dot \w+', _deob, s)
    # Deobfuscate partial: "word dot word@domain" (has @ but still has ' dot ')
    def _deob_partial(m):
        return m.group(0).replace(' dot ', '.')
    s = re.sub(r'\b\w[\w.+-]* dot \w[\w.+-]*@[\w.-]+', _deob_partial, s)
    # Convert parenthesized email to angle brackets
    s = re.sub(r'\(([\w.+-]+@[\w.-]+)\)', r'<\1>', s)
    return s

def _strip_email(s):
    """Remove <email> from a string."""
    return re.sub(r'\s*<[^>]*>', '', s).strip()

def _extract_email(s):
    """Extract the first email address from a string, or None."""
    m = re.search(r'<([^>]+)>', s)
    return m.group(1).lower() if m else None

def _normalize_unicode(s):
    """Decompose unicode and strip combining marks (accents).
    Also maps ß→ss (not decomposed by NFKD)."""
    nfkd = unicodedata.normalize('NFKD', s)
    result = ''.join(c for c in nfkd if unicodedata.category(c) != 'Mn')
    return result.replace('ß', 'ss').replace('ẞ', 'SS')

def norm_copyright(s):
    """Normalize a copyright string for fuzzy comparison.
    Strips year ranges, (c)/(C) markers, 'Copyright' prefix, email addresses,
    unicode accents, extra whitespace.
    Returns None if nothing meaningful remains."""
    s = s.strip()
    s = re.sub(r'(?i)copyright\s*', '', s)
    s = re.sub(r'\(c\)', '', s, flags=re.IGNORECASE)
    s = re.sub(r'©', '', s)
    # Remove year ranges: 2020, 2020-2024, 2020 - 2024, etc.
    s = re.sub(r'\d{4}\s*[-–]\s*\d{4}', '', s)
    s = re.sub(r'\d{4}', '', s)
    # Strip email addresses for comparison
    s = re.sub(r'\s*<[^>]*>', '', s)
    s = re.sub(r'[,;]\s*', ' ', s)
    s = re.sub(r'\s+', ' ', s).strip()
    # Normalize unicode (strip accents)
    s = _normalize_unicode(s)
    result = s.lower()
    return result if result else None

_YEAR_RE = re.compile(r'\b(\d{4})\b')
_YEAR_RANGE_RE = re.compile(r'\b(\d{4})\s*[-–]\s*(\d{4})\b')
_COPYRIGHT_PREFIX_RE = re.compile(
    r'^(?:copyright\s*)?(?:\(c\)|©)?\s*(?:copyright\s*)?\s*', re.IGNORECASE)

def _parse_copyright(s):
    """Split a raw copyright string into (set_of_years, author_name).
    Returns (years: set[int], author: str)."""
    years = set()
    for lo, hi in _YEAR_RANGE_RE.findall(s):
        lo_i, hi_i = int(lo), int(hi)
        if hi_i - lo_i > 200:  # sanity cap for malformed ranges
            years.add(lo_i)
            years.add(hi_i)
            continue
        for y in range(lo_i, hi_i + 1):
            years.add(y)
    for y in _YEAR_RE.findall(s):
        years.add(int(y))
    # Strip prefix, years, ranges, commas between years, leading "by"
    author = _COPYRIGHT_PREFIX_RE.sub('', s)
    author = _YEAR_RANGE_RE.sub('', author)
    author = _YEAR_RE.sub('', author)
    author = re.sub(r'^[\s,;-]+|[\s,;-]+$', '', author)
    author = re.sub(r'^by\s+', '', author, flags=re.IGNORECASE)
    author = re.sub(r'\s+', ' ', author).strip()
    return years, author

def _split_multi_authors(author_str):
    """Split a string containing multiple comma/and-separated authors.

    Handles patterns like:
      "Alice Brown, Bob Smith, and Carol Jones"
      "John Doe and Jane Doe"
    But does NOT split location qualifiers like:
      "Example GmbH, Munich."  → kept as one holder
      "Acme Ltd, Auckland, New Zealand" → kept as one holder
    Returns a list of individual author strings.
    """
    if not author_str:
        return [author_str]

    # When the string contains explicit "and"/"&", it is very likely
    # a multi-author list, so use a stricter location threshold (1 word)
    # to avoid merging real person names like "Bob Smith".
    has_explicit_sep = bool(re.search(r'\band\b|&', author_str,
                                      flags=re.IGNORECASE))
    loc_max_words = 1 if has_explicit_sep else 2

    # First split on explicit "and" / "&" separators — these always
    # indicate distinct authors.
    and_parts = re.split(
        r',\s*and\s+|,\s*&\s+|\s+and\s+|\s+&\s+',
        author_str, flags=re.IGNORECASE)
    and_parts = [p.strip() for p in and_parts if p and p.strip()]

    # Within each and-part, split on commas — but rejoin fragments
    # that look like locations (no year, no email, few words).
    result = []
    for and_part in and_parts:
        comma_parts = re.split(r',\s+', and_part)
        comma_parts = [p.strip() for p in comma_parts
                       if p and len(p.strip()) > 1]
        if not comma_parts:
            if and_part.strip():
                result.append(and_part.strip())
            continue
        merged = [comma_parts[0]]
        for p in comma_parts[1:]:
            # Corporate suffixes (Inc., Ltd., etc.) always rejoin
            if _CORP_SUFFIXES_RE.match(p.strip()):
                merged[-1] += ", " + p
                continue
            clean = re.sub(r'(?i)\.\s*all\s+rights\s+reserved\.?', '', p)
            clean = re.sub(r'[.\s]+$', '', clean).strip()
            has_email = '<' in p or '@' in p
            has_year = bool(re.search(r'\b\d{4}\b', p))
            word_count = len(clean.split())
            if not has_email and not has_year and word_count <= loc_max_words:
                # Likely a location qualifier — rejoin with previous
                merged[-1] += ", " + p
            else:
                merged.append(p)
        result.extend(merged)
    return result if result else [author_str]

def _infer_years_from_paths(paths):
    """Infer copyright years from file modification times.

    Tries git log for earliest commit year first, falls back to mtime.
    Returns a set of ints.
    """
    years = set()
    # Try git to get first-commit year per file (fast batch call)
    git_ok = os.path.isdir(".git")
    if git_ok and paths:
        try:
            clean = [p.lstrip("./") for p in paths if p]
            # Process in batches to avoid OS argument length limits
            _BATCH = 200
            for _bi in range(0, len(clean), _BATCH):
                _chunk = clean[_bi:_bi + _BATCH]
                result = subprocess.run(
                    ["git", "log", "--follow", "--diff-filter=A",
                     "--format=%ai", "--"] + _chunk,
                    capture_output=True, text=True, timeout=10)
                for line in result.stdout.splitlines():
                    line = line.strip()
                    if line and len(line) >= 4:
                        try:
                            years.add(int(line[:4]))
                        except ValueError:
                            pass
        except (OSError, subprocess.TimeoutExpired):
            pass
    # Fall back to mtime for files we couldn't get git info for
    if not years:
        for p in paths:
            clean_p = p.lstrip("./")
            try:
                mtime = os.path.getmtime(clean_p)
                years.add(datetime.datetime.fromtimestamp(mtime).year)
            except (OSError, ValueError):
                pass
    return years

def merge_copyrights(raw_list, prefer_unicode=None, file_paths=None):
    """Merge a list of raw copyright strings, combining years per author.

    Returns a list of formatted copyright lines, one per unique author,
    with years collapsed into ranges.
    Example: '2014-2016, 2020 David Edmundson <dave@kde.org>'

    If prefer_unicode is a dict mapping norm_key -> author string,
    any matching author will be replaced with the preferred (non-ASCII)
    variant.
    """
    # Group years by normalized author key → keep best-case original author
    author_years = {}    # norm_key → set of years
    author_best = {}     # norm_key → original author string (longest wins)
    email_to_key = {}    # email → first norm_key seen (for email-based dedup)

    def _author_score(a):
        has_nonascii = any(ord(c) > 127 for c in a)
        return (has_nonascii, len(a))

    # Placeholder values that scanners emit for unrecognized holders
    _PLACEHOLDER_HOLDERS = frozenset({
        "unknown", "fixme", "todo", "none", "n/a", "no copyright",
    })

    for raw in raw_list:
        if not raw or not raw.strip():
            continue
        # Filter out scanner false-positives (code fragments, non-person
        # text, template variables, etc.) before deobfuscation.
        if not _is_plausible_copyright(raw):
            continue
        raw = _deobfuscate_email(raw)
        years, author = _parse_copyright(raw)
        if not author:
            continue
        if author.lower().strip() in _PLACEHOLDER_HOLDERS:
            continue
        # Split multi-author strings ("A, B and C" → ["A", "B", "C"])
        individual_authors = _split_multi_authors(author)
        for indiv in individual_authors:
            if not indiv or indiv.lower().strip() in _PLACEHOLDER_HOLDERS:
                continue
            # Primary key: name without email, unicode-normalized, lowered
            name_only = _strip_email(indiv)
            key = _normalize_unicode(name_only).lower()
            # Email-based dedup: if two entries share the same email, merge them
            email = _extract_email(indiv)
            if email and email in email_to_key:
                key = email_to_key[email]
            elif email:
                email_to_key[email] = key
            if key not in author_years:
                author_years[key] = set()
                author_best[key] = indiv
            author_years[key].update(years)
            # Keep the best variant: prefer non-ASCII (proper unicode names)
            # over ASCII-only, then longest among same category.
            if _author_score(indiv) > _author_score(author_best[key]):
                author_best[key] = indiv

    # Secondary dedup: merge entries whose names match when whitespace
    # and hyphens are stripped.  Catches CamelCase/concatenated usernames
    # (e.g. "PushkarDeshpande") matching spaced names ("Pushkar Deshpande").
    compressed_map = {}  # compressed_key → canonical_key
    for key in sorted(author_years,
                      key=lambda k: (-len(author_best.get(k, '')), k)):
        ck = re.sub(r'[\s\-]+', '', key)
        if ck in compressed_map:
            canon = compressed_map[ck]
            author_years[canon].update(author_years.pop(key))
            if _author_score(author_best[key]) > _author_score(author_best[canon]):
                author_best[canon] = author_best[key]
            del author_best[key]
        else:
            compressed_map[ck] = key

    # Tertiary dedup: fuzzy match by edit distance to catch typos
    # like "Kontros" vs "Krontros" or "Adolfo" vs "Adolpho".
    def _edit_dist(a, b):
        if len(a) < len(b):
            a, b = b, a
        prev = list(range(len(b) + 1))
        for i, ca in enumerate(a):
            curr = [i + 1]
            for j, cb in enumerate(b):
                curr.append(min(prev[j + 1] + 1, curr[j] + 1,
                                prev[j] + (ca != cb)))
            prev = curr
        return prev[-1]

    remaining = list(author_years.keys())
    merged = set()
    for i, k1 in enumerate(remaining):
        if k1 in merged:
            continue
        ck1 = re.sub(r'[\s\-]+', '', k1)
        for k2 in remaining[i + 1:]:
            if k2 in merged:
                continue
            ck2 = re.sub(r'[\s\-]+', '', k2)
            if len(ck1) < 8 or len(ck2) < 8:
                continue
            if _edit_dist(ck1, ck2) <= 2:
                author_years[k1].update(author_years.pop(k2))
                if _author_score(author_best[k2]) > _author_score(author_best[k1]):
                    author_best[k1] = author_best[k2]
                del author_best[k2]
                merged.add(k2)

    def _fmt_years(yrs):
        """Collapse a set of years into compact ranges: 2014-2016, 2020."""
        if not yrs:
            return ""
        parts = []
        syrs = sorted(yrs)
        start = prev = syrs[0]
        for y in syrs[1:]:
            if y == prev + 1:
                prev = y
            else:
                parts.append(f"{start}-{prev}" if prev > start else str(start))
                start = prev = y
        parts.append(f"{start}-{prev}" if prev > start else str(start))
        return ", ".join(parts)

    # Apply unicode preferences from existing declarations
    if prefer_unicode:
        for key in author_best:
            if key in prefer_unicode:
                pref = prefer_unicode[key]
                cur = author_best[key]
                if any(ord(c) > 127 for c in pref) and not any(ord(c) > 127 for c in cur):
                    author_best[key] = pref

    # Infer fallback years for authors with no detected years
    has_yearless = any(not author_years[k] for k in author_years)
    if has_yearless:
        # First try: use years from peer authors in the same merge.
        # All raw copyrights cover the same files, so peer years are
        # the best available signal.
        peer_yrs = set()
        for k in author_years:
            peer_yrs.update(author_years[k])
        if peer_yrs:
            for key in author_years:
                if not author_years[key]:
                    author_years[key] = set(peer_yrs)
        # Second try: infer from file modification / git history.
        elif file_paths:
            fallback_yrs = _infer_years_from_paths(file_paths)
            if fallback_yrs:
                for key in author_years:
                    if not author_years[key]:
                        author_years[key] = set(fallback_yrs)

    lines = []
    for key in sorted(author_years, key=lambda k: (
            min(author_years[k]) if author_years[k] else 9999, k)):
        yrs = _fmt_years(author_years[key])
        name = author_best[key]
        if yrs:
            lines.append(f"{yrs} {name}")
        else:
            lines.append(name)
    return lines

def format_files_field(globs, max_col=80):
    """Format a complete Files: field with continuation indentation."""
    prefix = "Files: "
    indent = " " * len(prefix)
    lines = []
    for i, g in enumerate(globs):
        if i == 0:
            lines.append(prefix + g)
        else:
            lines.append(indent + g)
    return "\n".join(lines)

def _copyright_continuations(holders, max_col=80, indent=None):
    """Format holders as indented copyright continuation lines."""
    if indent is None:
        indent = " " * 11
    avail = max_col - len(indent)
    parts = []
    for h in holders:
        if len(h) <= avail:
            parts.append(indent + h)
        else:
            wrapped = textwrap.wrap(
                h, width=avail,
                break_long_words=False, break_on_hyphens=False)
            for wl in (wrapped or [h]):
                parts.append(indent + wl)
    return "\n".join(parts)

def format_copyright_field(merged_lines, max_col=80):
    """Format merged copyright lines into a DEP-5 Copyright: field value.
    First line follows 'Copyright:', subsequent lines are continuation.
    Wraps individual entries that would exceed max_col."""
    if not merged_lines:
        return "FIXME"
    indent = " " * 11
    avail = max_col - 11
    parts = []
    for i, line in enumerate(merged_lines):
        if len(line) <= avail:
            if i == 0:
                parts.append(line)
            else:
                parts.append(indent + line)
        else:
            wrapped = textwrap.wrap(
                line, width=avail,
                break_long_words=False, break_on_hyphens=False)
            for j, wl in enumerate(wrapped or [line]):
                if i == 0 and j == 0:
                    parts.append(wl)
                else:
                    parts.append(indent + wl)
    return "\n".join(parts)

def check_copyright_holders():
    """Compare detected copyright holders against declared ones per stanza.
    Returns: (missing, extra, star_file_map, holder_file_map)
      missing: [(glob, detected_holder)] — detected but not declared
      extra: [(glob, declared_holder)] — declared but not found in any scanned file
      star_file_map: {holder_string: set_of_file_paths} — for the * glob only,
        maps each missing holder to the files where it was detected
      holder_file_map: {(glob, holder_string): set_of_file_paths} — for ALL globs,
        maps each (glob, holder) to the specific files where it was detected
    """
    # In DEP-5, when multiple stanzas match a file, the last one wins.
    # Map each file's copyrights only to its governing stanza.
    detected_per_glob = defaultdict(set)
    declared_per_glob = {}
    # For the * glob, also track which files contribute each holder
    star_holder_files = defaultdict(set)  # holder_string → set of filepaths
    # Track per-file attribution for ALL globs
    holder_file_map = defaultdict(set)  # (glob, holder) → set of filepaths

    for glob_pat, lic in stanzas:
        declared_per_glob[glob_pat] = set(stanza_copyrights.get(glob_pat, []))

    # For each scanned file, find its governing stanza (last match wins)
    for r in scan_results:
        fp = r["path"].lstrip("./")
        # Skip license/copying text files — copyright notices in license texts
        # (e.g. FSF's copyright on the GPL text) are not project copyrights.
        if Path(fp).name.lower() in _LICENSE_FILE_NAMES:
            continue
        governing_glob = None
        for glob_pat, lic in stanzas:
            if fnmatch.fnmatch(fp, glob_pat) or glob_pat == "*":
                governing_glob = glob_pat
        if governing_glob is not None:
            # Detect GPL-family licenses on this file so we can suppress
            # FSF's copyright on the license text itself (not a project
            # copyright — scanners attribute it from the GPL boilerplate).
            _file_lics = " ".join(r.get("licenses", [])).lower()
            _is_gpl_family = bool(re.search(
                r'\b(gpl|lgpl|agpl|gfdl|gnu)\b', _file_lics))
            for c in r.get("copyrights", []):
                if c:
                    # Skip FSF copyright that scanners pull from GPL
                    # license boilerplate — this is the license author,
                    # not a project copyright holder.
                    if _is_gpl_family and re.search(
                            r'free\s+software\s+foundation',
                            c, re.IGNORECASE):
                        continue
                    detected_per_glob[governing_glob].add(c)
                    holder_file_map[(governing_glob, c)].add(fp)
                    if governing_glob == "*":
                        star_holder_files[c].add(fp)

    missing_holders = []  # (glob, detected_holder_not_declared)
    extra_holders = []    # (glob, declared_holder_not_detected)

    # Placeholder values that scanners emit for unrecognized holders
    _placeholder_holders = frozenset({
        "unknown", "fixme", "todo", "none", "n/a", "no copyright",
    })

    for glob_pat in declared_per_glob:
        declared_set = declared_per_glob[glob_pat]
        detected_set = detected_per_glob.get(glob_pat, set())

        declared_norms = {norm_copyright(d): d for d in declared_set
                          if norm_copyright(d)}
        detected_norms = {norm_copyright(d): d for d in detected_set
                          if norm_copyright(d) and
                          _parse_copyright(d)[1].lower().strip()
                          not in _placeholder_holders}

        # Detected but not declared (new authors)
        for dn, original in detected_norms.items():
            if not dn:
                continue
            if not any(dn in dk or dk in dn for dk in declared_norms):
                missing_holders.append((glob_pat, original))

        # Declared but not detected (stale authors)
        for dk, original in declared_norms.items():
            if not dk:
                continue
            if not any(dk in dn or dn in dk for dn in detected_norms):
                extra_holders.append((glob_pat, original))

    return missing_holders, extra_holders, star_holder_files, holder_file_map

# ══════════════════════════════════════════════════════════════════════
# Stanza consolidation detection
# ══════════════════════════════════════════════════════════════════════

def _parse_paragraph_stanzas(text):
    """Parse copyright text into paragraph-level stanzas.

    Returns list of dicts:
    [{"files": [str], "cops": [str], "license": str,
      "comment": str or None, "para_text": str}]
    Only includes paragraphs that have Files:, Copyright:, and License:.
    """
    result = []
    for para in text.split("\n\n"):
        files = []
        cops = []
        lic = None
        comment_lines = []
        current_field = None
        for line in para.split("\n"):
            if line.startswith("Files:"):
                rest = line[6:].strip()
                if rest:
                    files.extend(rest.split())
                current_field = "files"
            elif line.startswith("Copyright:"):
                cop_val = line[10:].strip()
                if cop_val:
                    cops.append(cop_val)
                current_field = "copyright"
            elif line.startswith("License:"):
                lic = line[8:].strip().split()[0] if line[8:].strip() else None
                current_field = "license"
            elif line.startswith("Comment:"):
                comment_lines.append(line[8:].strip())
                current_field = "comment"
            elif current_field == "files" and line.startswith(" ") and not line.startswith(" ."):
                files.extend(line.strip().split())
            elif current_field == "copyright" and line.startswith(" ") and line.strip():
                cops.append(line.strip())
            elif current_field == "comment" and line.startswith(" "):
                comment_lines.append(line[1:] if line.startswith(" ") else line)
        comment = "\n".join(comment_lines) if comment_lines else None
        if files and lic and cops:
            result.append({
                "files": files,
                "cops": cops,
                "license": lic,
                "comment": comment,
                "para_text": para,
            })
    return result

def check_consolidation(text=None):
    """Identify file stanzas that share the same license and author(s).

    Stanzas with identical (license, normalized copyright holder set) can
    be merged into a single stanza with all files listed and copyright
    years collapsed into ranges.

    Returns list of consolidation groups:
    [{"license": str, "stanzas": [dict], "merged_files": [str],
      "merged_cops": [str]}]
    Each group contains 2+ stanzas that could be consolidated.
    """
    src = text if text is not None else copyright_text
    para_stanzas = _parse_paragraph_stanzas(src)

    # Group by (license, normalized holders set)
    groups = defaultdict(list)
    for ps in para_stanzas:
        # Skip catch-all stanzas
        if ps["files"] == ["*"]:
            continue
        # Never consolidate stanzas that have Comment fields — they
        # carry per-file attribution (provenance URLs, etc.) that
        # would be lost in a merge.
        if ps.get("comment"):
            continue
        holder_keys = []
        for c in ps["cops"]:
            n = norm_copyright(c)
            if n:
                holder_keys.append(n)
        holder_key = frozenset(holder_keys)
        groups[(ps["license"], holder_key)].append(ps)

    # Return only groups with 2+ stanzas
    consolidatable = []
    for (lic, holder_key), stanza_list in sorted(groups.items()):
        if len(stanza_list) >= 2:
            # Merge files from all stanzas
            merged_files = []
            for ps in stanza_list:
                merged_files.extend(ps["files"])
            # Merge copyright lines (combine years per author)
            all_cops = []
            for ps in stanza_list:
                all_cops.extend(ps["cops"])
            merged_cops = merge_copyrights(all_cops, file_paths=merged_files)
            consolidatable.append({
                "license": lic,
                "stanzas": stanza_list,
                "merged_files": merged_files,
                "merged_cops": merged_cops,
            })
    return consolidatable

def check_over_broad_attribution(text=None):
    """Detect glob stanzas that attribute a holder to more files than detected.

    Returns list of dicts:
    [{"para": dict from _parse_paragraph_stanzas,
      "holder_raw": str,       # declared copyright line
      "holder_norm": str,      # normalized holder key
      "detected_files": [str], # files where holder was detected
      "governed_files": [str], # all files matching the stanza's globs
      "single_holder": bool}]  # True if the stanza has only one holder
    """
    src = text if text is not None else copyright_text
    para_stanzas = _parse_paragraph_stanzas(src)
    issues = []

    for ps in para_stanzas:
        if not any("*" in f for f in ps["files"]):
            continue
        if ps["files"] == ["*"]:
            continue

        # Collect all scanned files governed by this stanza's globs
        governed = set()
        for r in scan_results:
            fp = r["path"].lstrip("./")
            if any(fnmatch.fnmatch(fp, g) for g in ps["files"]):
                governed.add(fp)
        if len(governed) < 2:
            continue

        # For each declared holder, find which governed files have
        # that holder detected by the scanner
        holder_files = {}  # norm → set of filepaths
        holder_raw = {}    # norm → raw declared string
        for cop in ps["cops"]:
            nc = norm_copyright(cop)
            if not nc:
                continue
            holder_raw[nc] = cop
            holder_files[nc] = set()

        for fp in governed:
            for r in scan_results:
                rp = r["path"].lstrip("./")
                if rp != fp:
                    continue
                for c in r.get("copyrights", []):
                    if not c:
                        continue
                    nc_det = norm_copyright(c)
                    if not nc_det:
                        continue
                    for nc_decl in holder_files:
                        if nc_det in nc_decl or nc_decl in nc_det:
                            holder_files[nc_decl].add(fp)
                break

        # Find holders detected in some but not all files
        for nc, files in holder_files.items():
            if 0 < len(files) < len(governed):
                issues.append({
                    "para": ps,
                    "holder_raw": holder_raw[nc],
                    "holder_norm": nc,
                    "detected_files": sorted(files),
                    "governed_files": sorted(governed),
                    "single_holder": len(ps["cops"]) == 1,
                })

    return issues


def check_under_broad_attribution(text=None):
    """Detect stanzas where a declared holder is also detected in files
    NOT covered by the stanza's globs (attribution too narrow).

    Returns list of dicts:
    [{"para": dict from _parse_paragraph_stanzas,
      "holder_raw": str,       # declared copyright line
      "holder_norm": str,      # normalized holder key
      "uncovered_files": [str],# files where holder detected but not covered
      "stanza_files": [str]}]  # files currently governed by the stanza
    """
    src = text if text is not None else copyright_text
    para_stanzas = _parse_paragraph_stanzas(src)

    # Build map: norm_holder → all files where the scanner detected them
    holder_all_files = defaultdict(set)
    for r in scan_results:
        fp = r["path"].lstrip("./")
        if Path(fp).name.lower() in _LICENSE_FILE_NAMES:
            continue
        for c in r.get("copyrights", []):
            if not c:
                continue
            nc = norm_copyright(c)
            if nc:
                holder_all_files[nc].add(fp)

    issues = []

    for ps in para_stanzas:
        if ps["files"] == ["*"]:
            continue
        # Require at least one glob pattern — explicit file lists
        # are already precise and should not be widened.
        if not any("*" in f for f in ps["files"]):
            continue

        # Collect files governed by this stanza's globs
        governed = set()
        for r in scan_results:
            fp = r["path"].lstrip("./")
            if any(fnmatch.fnmatch(fp, g) for g in ps["files"]):
                governed.add(fp)

        for cop in ps["cops"]:
            nc = norm_copyright(cop)
            if not nc:
                continue

            # Find all files where this holder was detected
            detected_all = set()
            for nc_det, files in holder_all_files.items():
                if nc_det in nc or nc in nc_det:
                    detected_all |= files

            if not detected_all:
                continue

            # Files detected but NOT covered by this stanza
            uncovered = detected_all - governed
            if not uncovered:
                continue

            # Exclude files covered by OTHER non-catch-all stanzas
            # that also declare this holder
            for other_ps in para_stanzas:
                if other_ps is ps:
                    continue
                if other_ps["files"] == ["*"]:
                    continue
                other_norms = [norm_copyright(c) for c in other_ps["cops"]]
                if any(on and (on in nc or nc in on) for on in other_norms):
                    uncovered = {
                        fp for fp in uncovered
                        if not any(fnmatch.fnmatch(fp, g)
                                   for g in other_ps["files"])}
            if not uncovered:
                continue

            issues.append({
                "para": ps,
                "holder_raw": cop,
                "holder_norm": nc,
                "uncovered_files": sorted(uncovered),
                "stanza_files": sorted(governed),
            })

    return issues


def _simplify_file_list(paths, all_dir_counts=None, all_files=None):
    """Reduce a list of file paths using glob patterns where possible.

    For each directory, if the stanza owns ALL files in that directory,
    represent them as dir/*.  If *all_dir_counts* is provided (a dict
    mapping directory → total file count across ALL stanzas), dir/* is
    only used when the stanza's files in that directory equal the total.
    This prevents a stanza for one author from claiming dir/* when other
    authors also have files in the same directory.

    When dir/* is not safe, extension-based wildcards (dir/*.png) are
    tried when 2+ files share an extension and no collateral files of
    that extension exist outside *paths*.  *all_files* (set of all known
    file paths) is used for the collateral check; if not provided,
    extension globbing is skipped.
    """
    dirs = defaultdict(list)
    for p in paths:
        d = str(Path(p).parent)
        dirs[d].append(p)

    all_dirs = set(dirs.keys())

    # Phase 1: collapse leaf directories where we have 2+ files.
    # Skip directories that have sub-directories in the file list
    # (dir/* in DEP-5 matches recursively).
    # Only use dir/* when the stanza owns ALL files in the directory.
    phase1 = []
    for d, files in sorted(dirs.items()):
        has_subdirs = any(od != d and od.startswith(d + "/")
                         for od in all_dirs)
        owns_all = True
        if all_dir_counts and d in all_dir_counts:
            owns_all = len(files) >= all_dir_counts[d]
        if d == "." or len(files) < 2 or has_subdirs or not owns_all:
            # Try extension-based wildcards (dir/*.ext) when we can't
            # collapse the whole directory
            if all_files and d != "." and len(files) >= 2:
                ext_groups = defaultdict(list)
                for fp in files:
                    ext = Path(fp).suffix.lower()
                    if ext:
                        ext_groups[ext].append(fp)
                    # No extension → stays individual
                remaining = [fp for fp in files
                             if not Path(fp).suffix.lower()]
                for ext, ext_files in sorted(ext_groups.items()):
                    if len(ext_files) < 2:
                        remaining.extend(ext_files)
                        continue
                    proposed = d + "/*" + ext
                    collateral = [
                        f for f in all_files
                        if fnmatch.fnmatch(f, proposed)
                        and f not in set(paths)]
                    if not collateral:
                        phase1.append(proposed)
                    else:
                        remaining.extend(ext_files)
                phase1.extend(sorted(remaining))
            else:
                phase1.extend(sorted(files))
        else:
            phase1.append(d + "/*")

    # Phase 2: merge sibling dir/* globs that share a common prefix.
    # e.g. src/FooA/*, src/FooB/*, src/FooC/* → src/Foo*/*
    dir_globs = [g for g in phase1 if g.endswith("/*")]
    individual = [g for g in phase1 if not g.endswith("/*")]

    # Group dir/* globs by parent directory
    parent_groups = defaultdict(list)
    for g in dir_globs:
        parent = str(Path(g.rstrip("/*")).parent)
        parent_groups[parent].append(g)

    merged_globs = []
    for parent, globs in sorted(parent_groups.items()):
        if len(globs) < 2:
            merged_globs.extend(globs)
            continue
        # Find longest common prefix of the directory names
        dir_names = [g.rstrip("/*") for g in globs]
        prefix = os.path.commonprefix(dir_names)
        # prefix must end at a meaningful boundary and be longer than
        # just the parent dir + separator
        min_prefix = parent + "/" if parent != "." else ""
        if len(prefix) > len(min_prefix) + 1:
            # Check the proposed glob covers only the intended dirs
            pattern = prefix + "*/*"
            # Verify it doesn't accidentally match individual files
            # that are not part of any dir/* glob in this group
            merged_globs.append(pattern)
        else:
            merged_globs.extend(sorted(globs))

    result = sorted(merged_globs + individual)

    # Deduplicate: if parent/* or parent*/* covers a more specific glob
    deduped = []
    for g in result:
        if any(g != prev and fnmatch.fnmatch(g, prev)
               for prev in deduped):
            continue
        deduped.append(g)
    return deduped

# ══════════════════════════════════════════════════════════════════════
# Superfluous pattern / stanza detection
# ══════════════════════════════════════════════════════════════════════

def check_superfluous(text=None):
    """Identify superfluous file patterns and stanzas in debian/copyright.

    A pattern is superfluous if it matches no scanned file (stale).
    An entire stanza is superfluous if:
      - ALL its patterns match no files, OR
      - its license and copyright holders are identical to (or a subset
        of) those in the catch-all Files: * stanza.

    Returns a list of dicts:
      [{"stanza": dict, "reason": str, "stale_patterns": [str],
        "kept_patterns": [str], "remove_entirely": bool}]
    """
    src = text if text is not None else copyright_text
    para_stanzas = _parse_paragraph_stanzas(src)
    scanned_paths = set(r["path"].lstrip("./") for r in scan_results)

    # Find catch-all info
    catchall_lic = None
    catchall_cops_norm = set()
    for ps in para_stanzas:
        if ps["files"] == ["*"]:
            catchall_lic = ps["license"]
            catchall_cops_norm = {norm_copyright(c)
                                  for c in ps["cops"]
                                  if norm_copyright(c)}
            break

    results = []
    for ps in para_stanzas:
        if ps["files"] == ["*"]:
            continue

        stale_pats = []
        kept_pats = []
        for pat in ps["files"]:
            if any(fnmatch.fnmatch(sp, pat) for sp in scanned_paths):
                kept_pats.append(pat)
            else:
                stale_pats.append(pat)

        if not kept_pats and stale_pats:
            # All patterns are stale → entire stanza is superfluous
            results.append({
                "stanza": ps,
                "reason": "all file patterns match no files",
                "stale_patterns": stale_pats,
                "kept_patterns": [],
                "remove_entirely": True,
            })
        elif stale_pats:
            # Some patterns are stale
            results.append({
                "stanza": ps,
                "reason": "some file patterns match no files",
                "stale_patterns": stale_pats,
                "kept_patterns": kept_pats,
                "remove_entirely": False,
            })
        elif catchall_lic is not None:
            # Check if stanza is redundant with catch-all
            stanza_cops_norm = {norm_copyright(c)
                                for c in ps["cops"]
                                if norm_copyright(c)}
            if (licenses_compatible(ps["license"], catchall_lic)
                    and licenses_compatible(catchall_lic, ps["license"])
                    and stanza_cops_norm
                    and stanza_cops_norm <= catchall_cops_norm):
                results.append({
                    "stanza": ps,
                    "reason": "redundant with catch-all "
                              "(same license and copyright holders)",
                    "stale_patterns": [],
                    "kept_patterns": ps["files"],
                    "remove_entirely": True,
                })

    return results

# ══════════════════════════════════════════════════════════════════════
# False-positive heuristic
# ══════════════════════════════════════════════════════════════════════
def classify_fp(filepath, detected, declared):
    fp = filepath.lstrip("./")
    if re.search(r'(?:^|/)LICENSE[^/]*\.txt$', fp, re.I):
        return "license-text-file"
    if re.search(r'conftools/|\.m4$|config\.guess$|config\.sub$|ltmain', fp, re.I):
        return "autoconf-boilerplate"
    if fp.endswith(".rst") and detected.lower() in (
            "cecill", "cecill-c", "bsl-1.0", "apache-2.0", "gpl-2"):
        return "doc-embeds-plugin-license"
    if re.search(r'(CMakeLists\.txt|\.pro)$', fp):
        return "project-build-file"
    if fp.startswith("tools/"):
        return "tools-infrastructure"
    if is_metadata_file(filepath):
        return "metadata-infrastructure"
    return None

def licenses_compatible(detected_raw, declared):
    """Check if a detected license is compatible with what's declared.

    Handles the full GPL/LGPL/AGPL family, BSD/MIT aliases,
    and 'or-later' subsumption rules.
    """
    dn = norm_cmp(declared)
    dn2 = norm_cmp(detected_raw)
    if dn == dn2:
        return True

    # "or-later" (+) declared subsumes the base version and higher
    # e.g. GPL-2+ declared covers GPL-2, GPL-3, GPL-3+ detected
    GPL_VERSIONS = {
        "gpl-1": 1, "gpl-1+": 1, "gpl-2": 2, "gpl-2+": 2,
        "gpl-3": 3, "gpl-3+": 3, "gpl": 2,
    }
    LGPL_VERSIONS = {
        "lgpl-2": 2.0, "lgpl-2+": 2.0, "lgpl-2.1": 2.1, "lgpl-2.1+": 2.1,
        "lgpl-3": 3.0, "lgpl-3+": 3.0,
    }
    AGPL_VERSIONS = {
        "agpl-1": 1, "agpl-1+": 1,
        "agpl-3": 3, "agpl-3+": 3,
    }
    GFDL_VERSIONS = {
        "gfdl-1.1": 1.1, "gfdl-1.1+": 1.1,
        "gfdl-1.2": 1.2, "gfdl-1.2+": 1.2,
        "gfdl-1.3": 1.3, "gfdl-1.3+": 1.3,
    }

    for family_map in (GPL_VERSIONS, LGPL_VERSIONS, AGPL_VERSIONS, GFDL_VERSIONS):
        dv = family_map.get(dn)
        d2v = family_map.get(dn2)
        if dv is not None and d2v is not None:
            is_plus_declared = dn.endswith("+")
            is_plus_detected = dn2.endswith("+")
            # Exact family match
            if dv == d2v:
                # GPL-2+ declared covers GPL-2 detected (and vice versa if
                # the declared is at least as permissive)
                if is_plus_declared:
                    return True
                if not is_plus_detected:
                    return True  # both are -only, same version
            # or-later declared covers higher versions
            if is_plus_declared and d2v >= dv:
                return True
            # Don't flag or-later detected against a higher -only declared
            # e.g. GPL-2+ detected, GPL-3 declared  (could be exercised as GPL-3)
            if is_plus_detected and dv >= d2v:
                return True
            break  # Same family, versions don't match

    # BSD family: BSD-2-Clause and BSD-3-Clause are often interchangeable
    # in practice (scanner may detect one, copyright declares the other)
    bsd_set = {"bsd-2-clause", "bsd-3-clause", "bsd"}
    if dn in bsd_set and dn2 in bsd_set:
        return True

    # MPL-2.0 with secondary license exception is compatible with GPL
    if "mpl-2" in dn2 and "gpl" in dn:
        return True

    # LGPL code can be distributed under GPL (LGPL Section 3 / LGPL-2.1 §3).
    # LGPL-2+ → GPL-2+, LGPL-2.1+ → GPL-2+, LGPL-3 → GPL-3+, etc.
    # LGPL-2.1 maps to GPL-2 (not 2.1), so compare with int(lgpl_v).
    if dn2 in LGPL_VERSIONS and dn in GPL_VERSIONS:
        lgpl_v = LGPL_VERSIONS[dn2]
        gpl_v = GPL_VERSIONS[dn]
        gpl_min = int(lgpl_v)  # LGPL-2.1 → GPL-2, LGPL-3 → GPL-3
        is_plus_declared = dn.endswith("+")
        if is_plus_declared and gpl_v >= gpl_min:
            return True
        if not is_plus_declared and gpl_v >= gpl_min:
            return True

    # Expat / MIT are equivalent
    expat_set = {"expat", "mit"}
    if dn in expat_set and dn2 in expat_set:
        return True

    return False

# Non-free / non-DFSG licenses that warrant a warning.
# Note: AGPL-3 and CeCILL-2.1 are DFSG-free; they are NOT listed here.
_NONFREE_RAW = {
    "sspl-1.0",           # Server Side Public License
    "busl-1.1",           # Business Source License (MariaDB, HashiCorp, etc.)
    "commons-clause",     # Commons Clause (added to otherwise open licenses)
    "elastic-2.0",        # Elastic License 2.0
    "json",               # JSON License ("Good, not Evil" — non-free per Debian)
    "rsal-2.0",           # Redis Source Available License
    "polyform-noncommercial-1.0.0",  # PolyForm Noncommercial
    "polyform-small-business-1.0.0", # PolyForm Small Business
    "polyform-shield-1.0.0",         # PolyForm Shield
    "hippocratic-2.1",    # Hippocratic License (ethical use restriction)
    "confluent-community-1.0",  # Confluent Community License
    "apsl-1.0",           # Apple PSL 1.0 (non-free per FSF)
    "apsl-1.1",           # Apple PSL 1.1 (non-free per FSF)
    "watcom-1.0",         # Sybase Open Watcom (choice-of-venue / publish-changes)
}
NONFREE = {norm_cmp(x) for x in _NONFREE_RAW}

# CC-NC (NonCommercial) and CC-ND (NoDerivatives) are non-free per DFSG.
# CC-BY and CC-BY-SA (without NC/ND) are DFSG-free and NOT listed here.
def is_nonfree(license_id):
    """Check if a license is non-free (DFSG-incompatible)."""
    n = norm_cmp(license_id)
    if n in NONFREE:
        return True
    # CC licenses with NC or ND restrictions (with or without version)
    n_lower = n.lower()
    if "cc-by-nc" in n_lower or "cc-by-nd" in n_lower:
        return True
    return False

# ══════════════════════════════════════════════════════════════════════
# Helper: warn about unresolved stubs in written output
# ══════════════════════════════════════════════════════════════════════
def _warn_stubs(text, dest):
    """Print a loud warning if the output contains unresolved FIXME/TODO stubs."""
    stub_re = re.compile(r'\b(FIXME|TODO|PLACEHOLDER)\b', re.IGNORECASE)
    stub_lines = [(i + 1, ln) for i, ln in enumerate(text.splitlines())
                  if stub_re.search(ln)]
    if not stub_lines:
        return
    bar = "!" * 60
    print(f"\n{bar}", file=sys.stderr)
    print(f"  WARNING: {len(stub_lines)} unresolved stub(s) in {dest}",
          file=sys.stderr)
    print(bar, file=sys.stderr)
    for lineno, ln in stub_lines:
        print(f"  Line {lineno:4d}: {ln.strip()}", file=sys.stderr)
    print(f"{bar}\n", file=sys.stderr)

# ══════════════════════════════════════════════════════════════════════
# MODE: generate — build a debian/copyright from scan results
# ══════════════════════════════════════════════════════════════════════
if mode == "generate":
    # Collect per-file data, skipping metadata/infrastructure files
    # (those are covered by the Files: * catch-all stanza).
    file_entries = []
    for idx, r in enumerate(scan_results, 1):
        progress(idx, total_files, "Grouping")
        if not r["licenses"]:
            continue
        fp = r["path"].lstrip("./")
        if is_metadata_file(fp):
            continue  # covered by Files: * catch-all
        lic_key = tuple(sorted(set(to_dep5(l) for l in r["licenses"])))
        # Filter FSF boilerplate copyright from GPL-family files — the FSF
        # holds the copyright on the GPL text itself, not on the project.
        _file_lics = " ".join(r.get("licenses", [])).lower()
        _is_gpl = bool(re.search(r'\b(gpl|lgpl|agpl|gfdl|gnu)\b', _file_lics))
        cops = [
            c for c in r["copyrights"]
            if not (_is_gpl and re.search(
                r'free\s+software\s+foundation', c, re.IGNORECASE))
        ]
        file_entries.append({"path": fp, "lic": lic_key, "cops": cops})

    # Count files per license to pick the catch-all license
    lic_counts = defaultdict(int)
    for e in file_entries:
        lic_counts[e["lic"]] += 1
    if not lic_counts:
        print("No licenses detected in any file. Nothing to generate.", file=sys.stderr)
        sys.exit(1)
    biggest_key = max(lic_counts, key=lambda k: lic_counts[k])

    def _copyright_holder_key(cop):
        """Extract normalized author identity from a copyright string,
        stripping year ranges, emails, and spaces so that the same person
        with different formatting is recognized as one author."""
        s = cop.strip()
        s = re.sub(r'^(?:Copyright\s+)?(?:\(c\)\s+)?', '', s, flags=re.I)
        s = re.sub(r'^[\d,\s-]+', '', s).strip()
        s = re.sub(r'\s*<[^>]*>', '', s)   # strip email
        s = re.sub(r'[\s\-]+', '', s)      # collapse spaces/hyphens
        return s.lower()

    def _filter_debian(paths):
        if is_native:
            return paths
        return [p for p in paths if not p.startswith("debian/")]

    # ── Build per-author stanza groups ───────────────────────────────
    # Group files by (license, normalized-copyright-holder-set) so that
    # files with the same author(s) and license share a stanza.  This
    # produces per-author attribution rather than broad dir/* globs
    # that lump unrelated copyright holders together.
    root_catchall_cops = set()
    stanza_groups = defaultdict(lambda: {"paths": [], "cops": []})
    for e in file_entries:
        d = str(Path(e["path"]).parent)
        if e["lic"] == biggest_key and d == ".":
            root_catchall_cops.update(e["cops"])
            continue
        # Group by (license, normalized author set)
        author_key = frozenset(
            _copyright_holder_key(c) for c in e["cops"] if c.strip())
        sk = (e["lic"], author_key)
        stanza_groups[sk]["paths"].append(e["path"])
        stanza_groups[sk]["cops"].extend(e["cops"])

    # Count scanned non-metadata files per directory (for dir/* decision)
    dir_file_counts = defaultdict(int)
    for e in file_entries:
        d = str(Path(e["path"]).parent)
        if d != ".":
            dir_file_counts[d] += 1

    def smart_glob(paths):
        """Use dir/* only when every scanned non-metadata file in a
        directory belongs to this group; otherwise list individual files.
        Then merge sibling dir/* globs sharing a common prefix."""
        dirs = defaultdict(list)
        for p in paths:
            d = str(Path(p).parent)
            dirs[d].append(p)
        all_dirs = set(dirs.keys())
        result = []
        for d, files in sorted(dirs.items()):
            # Don't glob a dir if subdirs of it also appear in the
            # file list — dir/* in DEP-5 matches recursively.
            has_subdirs = any(od != d and od.startswith(d + "/")
                             for od in all_dirs)
            if d == ".":
                result.extend(sorted(files))
            elif (len(files) == dir_file_counts.get(d, 0)
                  and len(files) > 1 and not has_subdirs):
                result.append(d + "/*")
            else:
                result.extend(sorted(files))

        # Merge sibling dir/* globs with a common prefix into prefix*/*
        dir_globs = [g for g in result if g.endswith("/*")]
        individual = [g for g in result if not g.endswith("/*")]
        parent_groups = defaultdict(list)
        for g in dir_globs:
            parent = str(Path(g.rstrip("/*")).parent)
            parent_groups[parent].append(g)
        merged_globs = []
        for parent, globs in sorted(parent_groups.items()):
            if len(globs) < 3:
                merged_globs.extend(globs)
                continue
            dir_names = [g.rstrip("/*") for g in globs]
            prefix = os.path.commonprefix(dir_names)
            min_prefix = (parent + "/") if parent != "." else ""
            if len(prefix) > len(min_prefix) + 1:
                merged_globs.append(prefix + "*/*")
            else:
                merged_globs.extend(sorted(globs))
        result = sorted(merged_globs + individual)

        # Deduplicate: if parent/* covers child/*, drop child
        deduped = []
        for g in sorted(result):
            if any(g != prev and fnmatch.fnmatch(g, prev)
                   for prev in deduped):
                continue
            deduped.append(g)
        return deduped

    out = []
    out.append("Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/")
    out.append("Upstream-Name: FIXME")
    out.append("Upstream-Contact: FIXME")
    out.append("Source: FIXME")
    out.append("")

    # Catch-all stanza — only root-level copyrights for the dominant license.
    # If no root-level files have copyright info, use the packager identity.
    merged_cops = merge_copyrights(sorted(root_catchall_cops))
    if not merged_cops:
        _catchall_cop = ""
        if packager_name or packager_email:
            parts = []
            if packager_year:
                parts.append(packager_year)
            if packager_name:
                parts.append(packager_name)
            if packager_email:
                parts.append(f"<{packager_email}>")
            _catchall_cop = " ".join(parts)
        merged_cops = [_catchall_cop] if _catchall_cop else []
    out.append("Files: *")
    out.append("Copyright: " + format_copyright_field(merged_cops))
    out.append("License: " + " AND ".join(biggest_key))
    out.append("")

    # Emit per-author stanzas — each group shares the same license and
    # the same set of copyright holders (regardless of year differences).
    for sk in sorted(stanza_groups,
                     key=lambda k: (k[0], sorted(stanza_groups[k]["paths"])[0])):
        lic_key, _author_fs = sk
        grp = stanza_groups[sk]
        paths = sorted(_filter_debian(grp["paths"]))
        if not paths:
            continue
        cops_raw = sorted(set(grp["cops"]))
        merged_cops = merge_copyrights(cops_raw, file_paths=paths)

        if not merged_cops:
            # No copyright holders detected.  If the license matches the
            # catch-all, these files are already covered by Files: * — no
            # stanza needed.  Otherwise emit a stanza for the differing
            # license with FIXME (user must fill in the real holders).
            if lic_key == biggest_key:
                continue

        globs = smart_glob(paths)

        out.append(format_files_field(globs))
        out.append("Copyright: " + format_copyright_field(merged_cops))
        out.append("License: " + " AND ".join(lic_key))
        out.append("")

    # ── debian/* packaging stanza ─────────────────────────────────
    # For non-native packages, propose a dedicated debian/* stanza with
    # the packager's identity.  Native packages (3.0 (native)) have no
    # upstream/debian split so this stanza is not applicable.
    deb_lic_added = None

    # Interactive prompt (read from /dev/tty since stdout may be piped)
    try:
        _gen_tty = open("/dev/tty", "r")
    except OSError:
        _gen_tty = None

    def gen_ask(prompt):
        if yes_mode:
            sys.stderr.write(prompt + "y (--yes)\n")
            sys.stderr.flush()
            return "y"
        sys.stderr.write(prompt)
        sys.stderr.flush()
        if _gen_tty:
            try:
                return _gen_tty.readline().rstrip("\n")
            except EOFError:
                return ""
        return ""

    if not is_native:
        # Build packager copyright string from detected identity
        packager_cop = ""
        if packager_name or packager_email:
            parts = []
            if packager_year:
                parts.append(packager_year)
            if packager_name:
                parts.append(packager_name)
            if packager_email:
                parts.append(f"<{packager_email}>")
            packager_cop = " ".join(parts)

        deb_lic = "GPL-2+"
        deb_cop = packager_cop or "FIXME"

        print("\n  Proposing debian/* packaging copyright:", file=sys.stderr)
        print(f"  ┌──────────────────────────────────────", file=sys.stderr)
        print(f"  │ Files: debian/*", file=sys.stderr)
        print(f"  │ Copyright: {deb_cop}", file=sys.stderr)
        print(f"  │ License: {deb_lic}", file=sys.stderr)
        print(f"  └──────────────────────────────────────", file=sys.stderr)

        answer = gen_ask("  Accept this stanza? [Y/n/e(dit)] ").strip().lower()
        if answer in ("e", "edit"):
            print("  Enter copyright holder (or press Enter to keep):", file=sys.stderr)
            new_cop = gen_ask(f"    Copyright [{deb_cop}]: ").strip()
            if new_cop:
                deb_cop = new_cop
            print("  Enter license (or press Enter to keep):", file=sys.stderr)
            new_lic = gen_ask(f"    License [{deb_lic}]: ").strip()
            if new_lic:
                deb_lic = new_lic
            answer = "y"

        if answer not in ("n", "no"):
            out.append("Files: debian/*")
            out.append(f"Copyright: {deb_cop}")
            out.append(f"License: {deb_lic}")
            out.append("")
            deb_lic_added = deb_lic
            print("  ✓ debian/* stanza added.", file=sys.stderr)
        else:
            print("  ✗ debian/* stanza skipped.", file=sys.stderr)
    else:
        print("\n  Native package — skipping debian/* stanza.", file=sys.stderr)

    if _gen_tty:
        _gen_tty.close()

    # License text stubs
    all_lics = set()
    all_lics.update(biggest_key)
    for (lic_key, _author_fs) in stanza_groups:
        all_lics.update(lic_key)
    if deb_lic_added:
        all_lics.add(deb_lic_added)

    # Licenses with full text available in /usr/share/common-licenses/
    COMMON_LICENSES = {
        "GPL-1", "GPL-1+", "GPL-2", "GPL-2+", "GPL-3", "GPL-3+",
        "LGPL-2", "LGPL-2+", "LGPL-2.1", "LGPL-2.1+", "LGPL-3", "LGPL-3+",
        "AGPL-3", "AGPL-3+",
        "Apache-2.0", "MPL-1.1", "MPL-2.0",
        "GFDL", "GFDL-1.1", "GFDL-1.1+", "GFDL-1.2", "GFDL-1.2+", "GFDL-1.3", "GFDL-1.3+",
        "Artistic", "BSD", "CC0-1.0",
    }

    # Creative Commons licenses that need the full legal text embedded
    CC_NEEDS_FULL_TEXT = {
        "CC-BY-1.0", "CC-BY-2.0", "CC-BY-2.5", "CC-BY-3.0", "CC-BY-4.0",
        "CC-BY-SA-1.0", "CC-BY-SA-2.0", "CC-BY-SA-2.5", "CC-BY-SA-3.0", "CC-BY-SA-4.0",
        "CC-BY-NC-1.0", "CC-BY-NC-2.0", "CC-BY-NC-2.5", "CC-BY-NC-3.0", "CC-BY-NC-4.0",
        "CC-BY-ND-1.0", "CC-BY-ND-2.0", "CC-BY-ND-2.5", "CC-BY-ND-3.0", "CC-BY-ND-4.0",
        "CC-BY-NC-SA-1.0", "CC-BY-NC-SA-2.0", "CC-BY-NC-SA-2.5", "CC-BY-NC-SA-3.0", "CC-BY-NC-SA-4.0",
        "CC-BY-NC-ND-1.0", "CC-BY-NC-ND-2.0", "CC-BY-NC-ND-2.5", "CC-BY-NC-ND-3.0", "CC-BY-NC-ND-4.0",
        "CC-PDDC",
    }

    fetched_count = 0
    for lic in sorted(all_lics):
        out.append(f"License: {lic}")
        if lic in COMMON_LICENSES:
            out.append(_dep5_common_license_body(lic))
        else:
            # Try to fetch the full text
            fetched = fetch_license_text(lic)
            if fetched:
                out.append(format_license_body(fetched))
                fetched_count += 1
                print(f"  ✓ Fetched full text for {lic}", file=sys.stderr)
            elif lic in CC_NEEDS_FULL_TEXT:
                out.append(f" FIXME: Include the FULL legal text of {lic} here.")
                out.append(f" Creative Commons licenses (except CC0-1.0) are NOT in")
                out.append(f" /usr/share/common-licenses/. lintian will flag an incomplete")
                out.append(f" CC license. Download the full text from:")
                tag = lic.lower().replace("cc-", "").rsplit("-", 1)
                variant = tag[0] if tag else lic.lower()
                version = tag[1] if len(tag) > 1 else "4.0"
                out.append(f" https://creativecommons.org/licenses/{variant}/{version}/legalcode.txt")
            else:
                out.append(" FIXME: Add the full license text here.")
                out.append(" This license is not in /usr/share/common-licenses/.")
        out.append("")

    result = "\n".join(out)
    if output_file:
        with open(output_file, "w", encoding="utf-8") as f:
            f.write(result)
        print(f"Generated copyright written to: {output_file}", file=sys.stderr)
        _warn_stubs(result, output_file)
    else:
        print(result)

    # ── Warnings: non-free and compatibility ──────────────────────
    nonfree_in_source = [lic for lic in all_lics if is_nonfree(lic)]
    if nonfree_in_source:
        print(f"\n  WARNING: {len(nonfree_in_source)} non-free/non-DFSG license(s) detected:", file=sys.stderr)
        for nf in sorted(nonfree_in_source):
            print(f"    - {nf}", file=sys.stderr)
        print("  These cannot be in Debian 'main'. Move to 'non-free' or resolve.", file=sys.stderr)

    compat_issues = check_license_compatibility(all_lics)
    if compat_issues:
        bar = "!" * 60
        print(f"\n{bar}", file=sys.stderr)
        print(f"  LICENSE INCOMPATIBILITY — {len(compat_issues)} conflict(s) detected", file=sys.stderr)
        print(bar, file=sys.stderr)
        for issue in compat_issues:
            print(f"    - {issue}", file=sys.stderr)
        print(f"{bar}\n", file=sys.stderr)

    # Summary to stderr
    print(f"\n  Files scanned:  {len(scan_results)}", file=sys.stderr)
    print(f"  License groups: {len(set(k[0] for k in stanza_groups)) + 1}", file=sys.stderr)
    print(f"  Unique licenses: {len(all_lics)}", file=sys.stderr)
    print(f"  Stanzas (excl. catch-all): {len(stanza_groups)}", file=sys.stderr)
    if fetched_count:
        print(f"  License texts fetched: {fetched_count}", file=sys.stderr)
    unfetched = len(all_lics) - fetched_count - len(all_lics & COMMON_LICENSES)
    if unfetched > 0 and not no_fetch:
        print(f"  License texts needing manual fill: {unfetched} (FIXME markers)", file=sys.stderr)
    elif no_fetch:
        print(f"  License text fetching disabled (--no-fetch)", file=sys.stderr)
    print(f"\n  Review the output carefully — FIXME markers need attention.", file=sys.stderr)
    sys.exit(0)

# ══════════════════════════════════════════════════════════════════════
# MODE: check / review — validate existing debian/copyright
# ══════════════════════════════════════════════════════════════════════

# Load decopy results if available
decopy_issues = []
decopy_file = os.path.join(os.path.dirname(scan_file), "decopy.txt")
if os.path.isfile(decopy_file):
    try:
        with open(decopy_file, encoding="utf-8") as f:
            decopy_raw = f.read().strip()
        if decopy_raw:
            decopy_issues = [l for l in decopy_raw.split("\n") if l.strip()]
    except Exception:
        pass

# Load sanity-check results from all available secondary scanners.
# When scancode is primary, secondaries may include licensecheck and/or decopy.
# When licensecheck is primary, the secondary is decopy.
all_sanity_results = {}  # tool_name → {path: set(licenses)}

def _parse_dep5_sanity(content):
    """Parse DEP-5 style output into {path: set(licenses)} for cross-validation."""
    results = {}
    SKIP_LIC = {"UNKNOWN"}
    for para in content.split("\n\n"):
        para = para.strip()
        if not para.startswith("Files:"):
            continue
        lines = para.split("\n")
        file_lines = []
        lic_line = None
        in_files = True
        for line in lines:
            if line.startswith("Files:"):
                rest = line[6:].strip()
                if rest:
                    file_lines.append(rest)
                in_files = True
            elif line.startswith("Copyright:"):
                in_files = False
            elif line.startswith("License:"):
                in_files = False
                lic_line = line[8:].strip()
            elif in_files and line.startswith(" "):
                file_lines.append(line.strip())
        if lic_line and lic_line not in SKIP_LIC:
            lics = set()
            for part in re.split(r'\s+and/or\s+', lic_line):
                part = part.strip("() ")
                base = re.split(r'\s+with\s+', part, flags=re.IGNORECASE)[0].strip()
                if base:
                    lics.add(base)
            for fp in file_lines:
                results[fp] = lics
    return results

if has_sanity_check:
    _sanity_files = {
        "licensecheck": "licensecheck_sanity.txt",
        "decopy": "decopy.txt",
    }
    for _st in sanity_tools:
        _sf = os.path.join(os.path.dirname(scan_file), _sanity_files.get(_st, ""))
        if os.path.isfile(_sf):
            try:
                with open(_sf, encoding="utf-8") as f:
                    all_sanity_results[_st] = _parse_dep5_sanity(f.read())
            except Exception:
                pass

def run_sanity_check(tool_name, sanity_results):
    """Cross-validate primary scanner results against a secondary scanner.
    Returns list of (path, primary_dep5, secondary_dep5) tuples where
    the tools disagree on the detected license family.
    All identifiers are DEP-5-normalized for display."""
    if not sanity_results:
        return []

    # Build lookup from primary scan results (DEP-5 normalised)
    primary = {}
    for r in scan_results:
        fp = r["path"]
        primary[fp] = set(to_dep5(l) for l in r["licenses"] if l)

    discrepancies = []
    for fp, lc_lics in sanity_results.items():
        # Normalize path
        norm = "./" + fp.lstrip("./")
        # Skip project-level license/copying/authors files — they
        # describe the project as a whole and routinely differ between
        # scanners without indicating a real problem.
        _bn = os.path.basename(norm).lower()
        if _bn in _LICENSE_FILE_NAMES or _bn in ("authors", "authors.txt",
                                                   "contributors",
                                                   "contributors.txt"):
            continue
        sc_lics = primary.get(norm, set())
        lc_dep5 = set(to_dep5(l) for l in lc_lics if l)
        if not sc_lics and not lc_dep5:
            continue
        # Normalize for comparison: strip versions, lowercase
        def _family(lic):
            s = re.sub(r'[- ]\d[\d.]*\+?$', '', lic).strip()
            return s.lower().replace("-", "").replace("_", "").replace(" ", "")
        sc_families = {_family(l) for l in sc_lics if l}
        lc_families = {_family(l) for l in lc_dep5 if l}
        # Skip if either found nothing (one tool may not detect headers)
        if not sc_families or not lc_families:
            continue
        # Flag if no overlap at all between the two tools
        if not sc_families & lc_families:
            discrepancies.append((
                norm,
                ", ".join(sorted(sc_lics)) or "(none)",
                ", ".join(sorted(lc_dep5)) or "(none)",
            ))
    return discrepancies

all_sanity_discrepancies = {}  # tool_name → [(path, primary_lics, secondary_lics)]
for _st, _sr in all_sanity_results.items():
    all_sanity_discrepancies[_st] = run_sanity_check(_st, _sr)

# ── Two-of-three consensus ───────────────────────────────────────────
# When all three scanners ran, compute consensus: for each file flagged
# by any pairwise comparison, check if two of three agree.  That file
# is downgraded from a warning to informational.
sanity_consensus = []   # [(path, {tool: lics, ...}, consensus_lic_or_None)]
sanity_real_disc = []   # files where all three disagree

if len(all_sanity_results) >= 2:
    # Gather every path that ANY secondary flagged
    _flagged_paths = set()
    for _sd in all_sanity_discrepancies.values():
        for fp, _, _ in _sd:
            _flagged_paths.add(fp)

    if _flagged_paths:
        # Build per-tool DEP-5 license sets for every flagged path
        _primary_lics = {}
        for r in scan_results:
            if r["path"] in _flagged_paths:
                _primary_lics[r["path"]] = set(to_dep5(l) for l in r["licenses"] if l)

        def _family(lic):
            s = re.sub(r'[- ]\d[\d.]*\+?$', '', lic).strip()
            return s.lower().replace("-", "").replace("_", "").replace(" ", "")

        for fp in sorted(_flagged_paths):
            tool_lics = {scanner: _primary_lics.get(fp, set())}
            for _st, _sr in all_sanity_results.items():
                raw = _sr.get(fp.lstrip("./"), _sr.get(fp, set()))
                tool_lics[_st] = set(to_dep5(l) for l in raw if l) if raw else set()

            # Compare families pairwise to find which tools agree.
            # Tools that returned no detection abstain — they do not
            # count as disagreements.
            tools = list(tool_lics.keys())
            _active_tools = [t for t in tools if tool_lics[t]]
            _agreements = []
            for i in range(len(_active_tools)):
                for j in range(i + 1, len(_active_tools)):
                    fi = {_family(l) for l in tool_lics[_active_tools[i]] if l}
                    fj = {_family(l) for l in tool_lics[_active_tools[j]] if l}
                    if fi & fj:
                        _agreements.append((_active_tools[i], _active_tools[j]))

            if _agreements:
                # At least two tools agree — pick the consensus license
                _winner_pair = _agreements[0]
                _consensus = ", ".join(sorted(
                    tool_lics[_winner_pair[0]] & tool_lics[_winner_pair[1]]
                )) or ", ".join(sorted(tool_lics[_winner_pair[0]]))
                sanity_consensus.append((fp, tool_lics, _consensus))
            elif len(_active_tools) >= 2:
                # Two or more tools detected something but none agree
                sanity_real_disc.append((fp, tool_lics))
            # else: only one (or zero) tools detected anything — no
            # consensus possible but not a real disagreement either

real_mismatches = defaultdict(list)
fp_mismatches = defaultdict(list)
no_license = []
apache_files = []
all_scan_licenses = defaultdict(int)

for idx, r in enumerate(scan_results, 1):
    progress(idx, total_files, "Classifying")
    fp = r["path"]
    if not r["licenses"]:
        no_license.append(fp)
        continue

    for raw_lic in r["licenses"]:
        dep5 = to_dep5(raw_lic)
        all_scan_licenses[dep5] += 1
        declared = get_declared_license(fp)
        if not declared:
            declared = "GPL-2+"

        if "apache" in raw_lic.lower():
            apache_files.append(fp)

        if licenses_compatible(raw_lic, declared):
            continue

        category = classify_fp(fp, raw_lic, declared)
        if category:
            fp_mismatches[category].append((fp, dep5, declared))
        else:
            real_mismatches[(dep5, declared)].append(fp)

W = 70
real_count = sum(len(v) for v in real_mismatches.values())
fp_count = sum(len(v) for v in fp_mismatches.values())

if mode == "review":
    # ── Reviewer report ───────────────────────────────────────────────
    print("=" * W)
    print("DEBIAN COPYRIGHT REVIEW REPORT")
    print("=" * W)
    print(f"  Scanner: {scanner}" +
          (" (SPDX database)" if scanner == "scancode" else " (heuristic)"))
    print(f"  Files scanned: {len(scan_results)}")
    print(f"  File stanzas in debian/copyright: {len(stanzas)}")
    print()

    # Test 1: DEP-5 format validation
    dep5_issues = check_dep5_format()
    dep5_errors = [i for i in dep5_issues if i[1] == "error"]
    dep5_warnings = [i for i in dep5_issues if i[1] == "warning"]
    if not dep5_issues:
        print("[PASS] DEP-5 format validation: no issues")
    elif dep5_errors:
        print(f"[FAIL] DEP-5 format validation: {len(dep5_errors)} error(s), "
              f"{len(dep5_warnings)} warning(s)")
        for lnum, sev, msg, _ in dep5_issues:
            prefix = f"Line {lnum}: " if lnum > 0 else ""
            print(f"        [{sev.upper()}] {prefix}{msg}")
    else:
        print(f"[WARN] DEP-5 format validation: {len(dep5_warnings)} warning(s)")
        for lnum, sev, msg, _ in dep5_issues:
            prefix = f"Line {lnum}: " if lnum > 0 else ""
            print(f"        [{sev.upper()}] {prefix}{msg}")

    # Test 2: Catch-all stanza
    has_catchall = any(g == "*" for g, _ in stanzas)
    status = "PASS" if has_catchall else "FAIL"
    print(f"[{status}] Catch-all Files: * stanza")

    # Test 3: Mismatches
    status = "PASS" if real_count == 0 else "WARN" if real_count < 10 else "FAIL"
    print(f"[{status}] License mismatches: {real_count}")
    if real_mismatches:
        for (det, decl), files in sorted(real_mismatches.items()):
            print(f"        Detected '{det}' but declared '{decl}' ({len(files)} file(s))")
            _lim = len(files) if verbose else 3
            for fp in files[:_lim]:
                print(f"          {fp}")
            if len(files) > _lim:
                print(f"          ... and {len(files) - _lim} more")

    # Test 4: Missing licenses
    declared_lics = set(lic for _, lic in stanzas)
    missing_in_copyright = set()
    for dep5, count in all_scan_licenses.items():
        if count >= 3:
            found = False
            for _, decl in stanzas:
                if norm_cmp(dep5) == norm_cmp(decl):
                    found = True
                    break
            if not found:
                missing_in_copyright.add(dep5)
    status = "PASS" if not missing_in_copyright else "WARN"
    print(f"[{status}] Undeclared licenses: {len(missing_in_copyright)}")
    for ml in sorted(missing_in_copyright):
        print(f"        {ml} (found in {all_scan_licenses[ml]} files)")

    # Test 5: License compatibility (check both declared and detected)
    compat_issues = check_license_compatibility(declared_lics)
    detected_compat = check_license_compatibility(set(all_scan_licenses.keys()))
    # Merge, dedup
    all_compat = list(dict.fromkeys(compat_issues + detected_compat))
    if all_compat:
        bar = "!" * W
        print(bar)
        print(f"[FAIL] LICENSE INCOMPATIBILITY — {len(all_compat)} conflict(s)")
        print(bar)
        for issue in all_compat:
            print(f"        {issue}")
        print(bar)
    else:
        print("[PASS] No license compatibility issues")

    # Test 6: Non-free
    nonfree_found = []
    for dep5, count in all_scan_licenses.items():
        if is_nonfree(dep5):
            nonfree_found.append(dep5)
    status = "PASS" if not nonfree_found else "WARN"
    print(f"[{status}] Non-free licenses in source: {len(nonfree_found)}")
    for nf in nonfree_found:
        print(f"        {nf}")

    # Test 7: Files with no license
    src_no_lic = [f for f in no_license if Path(f).suffix.lower() in SOURCE_EXTS]
    status = "PASS" if len(src_no_lic) == 0 else "INFO"
    print(f"[{status}] Source files without license header: {len(src_no_lic)}")

    # Test 8: License text completeness
    lic_issues = check_license_completeness()
    errors = [i for i in lic_issues if i[1] == "error"]
    warnings = [i for i in lic_issues if i[1] == "warning"]
    if not lic_issues:
        print("[PASS] License text blocks are complete")
    elif errors:
        print(f"[FAIL] License text completeness: {len(errors)} error(s), {len(warnings)} warning(s)")
        for lic, sev, msg in lic_issues:
            print(f"        [{sev.upper()}] {msg}")
    else:
        print(f"[WARN] License text completeness: {len(warnings)} warning(s)")
        for lic, sev, msg in lic_issues:
            print(f"        [{sev.upper()}] {msg}")

    # Test 9: Stanza coverage — uncovered files
    uncovered, stale = check_coverage()
    # Only flag source files as uncovered (data files are fine under catch-all)
    src_uncovered = [(fp, lics) for fp, lics in uncovered
                     if Path(fp).suffix.lower() in SOURCE_EXTS]
    status = "PASS" if len(src_uncovered) == 0 else (
        "INFO" if len(src_uncovered) < 5 else "WARN")
    print(f"[{status}] Source files not matched by any stanza: {len(src_uncovered)}")
    if src_uncovered:
        _lim = len(src_uncovered) if verbose else 5
        for fp, lics in src_uncovered[:_lim]:
            lic_str = ", ".join(lics) if lics else "(unknown)"
            print(f"        {fp}  [{lic_str}]")
        if len(src_uncovered) > _lim:
            print(f"        ... and {len(src_uncovered) - _lim} more")

    # Test 10: Stale stanza globs
    status = "PASS" if not stale else "WARN"
    print(f"[{status}] Stale stanza globs (match no file): {len(stale)}")
    _lim = len(stale) if verbose else 5
    for glob_pat, lic in stale[:_lim]:
        print(f"        {glob_pat}  ({lic})")
    if len(stale) > _lim:
        print(f"        ... and {len(stale) - _lim} more")

    # Test 10b: Superfluous stanzas/patterns
    _superfluous = check_superfluous()
    _sup_entire = [s for s in _superfluous if s["remove_entirely"]]
    _sup_partial = [s for s in _superfluous if not s["remove_entirely"]]
    if _superfluous:
        status = "WARN"
        print(f"[{status}] Superfluous stanzas/patterns: "
              f"{len(_sup_entire)} stanza(s) removable, "
              f"{len(_sup_partial)} with stale patterns")
        _lim = len(_superfluous) if verbose else 5
        for sup in _superfluous[:_lim]:
            ps = sup["stanza"]
            fp = " ".join(ps["files"][:3])
            if len(ps["files"]) > 3:
                fp += " ..."
            if sup["remove_entirely"]:
                print(f"        {fp}  ({sup['reason']})")
            else:
                print(f"        {fp}  (stale: "
                      f"{', '.join(sup['stale_patterns'])})")
        if len(_superfluous) > _lim:
            print(f"        ... and {len(_superfluous) - _lim} more")
    else:
        print("[PASS] No superfluous stanzas or patterns")

    print()
    lic_ok = not lic_issues or not errors
    cov_ok = len(src_uncovered) == 0
    dep5_ok = not dep5_errors

    # Test 11: Copyright holder accuracy
    missing_holders, extra_holders, _, holder_file_map = check_copyright_holders()
    if not missing_holders and not extra_holders:
        print("[PASS] Copyright holders match detected holders")
    else:
        if missing_holders:
            status = "WARN"
            print(f"[{status}] Undeclared copyright holders: "
                  f"{len(missing_holders)} detected in source but not in debian/copyright")
            _lim = len(missing_holders) if verbose else 3
            for glob_pat, holder in missing_holders[:_lim]:
                print(f"        + {glob_pat}: {holder}")
                if verbose:
                    _src_files = sorted(holder_file_map.get((glob_pat, holder), set()))
                    for _sf in _src_files[:5]:
                        print(f"            found in: {_sf}")
                    if len(_src_files) > 5:
                        print(f"            ... and {len(_src_files) - 5} more file(s)")
            if len(missing_holders) > _lim:
                print(f"        ... and {len(missing_holders) - _lim} more")
        if extra_holders:
            print(f"[INFO] Declared holders not found in file headers: "
                  f"{len(extra_holders)} (normal for files without embedded headers)")
            _lim = len(extra_holders) if verbose else 3
            for glob_pat, holder in extra_holders[:_lim]:
                print(f"        {glob_pat}: {holder}")
            if len(extra_holders) > _lim:
                print(f"        ... and {len(extra_holders) - _lim} more")

    # Test 13: Low-confidence detections
    if low_confidence:
        print(f"[INFO] Low-confidence license detections skipped: {len(low_confidence)}")
        _lim = len(low_confidence) if verbose else 5
        for path, lic, score in low_confidence[:_lim]:
            print(f"        {path}  ({lic}, score={score})")
        if len(low_confidence) > _lim:
            print(f"        ... and {len(low_confidence) - _lim} more")

    # Test 14: Stanza consolidation opportunities
    consol_groups = check_consolidation()
    if consol_groups:
        total_saving = sum(len(g["stanzas"]) - 1 for g in consol_groups)
        print(f"[INFO] Stanza consolidation: {len(consol_groups)} group(s), "
              f"{total_saving} stanza(s) can be merged")
        _lim = len(consol_groups) if verbose else 5
        for grp in consol_groups[:_lim]:
            n = len(grp["stanzas"])
            f_count = len(grp["merged_files"])
            print(f"        {grp['license']}: {n} stanzas → 1 "
                  f"({f_count} files, {', '.join(grp['merged_cops'][:2])})")
        if len(consol_groups) > _lim:
            print(f"        ... and {len(consol_groups) - _lim} more")
    else:
        print("[PASS] No stanza consolidation opportunities")

    # Test 15: FIXME entries in copyright file
    fixme_stanzas = []
    for para in copyright_text.split("\n\n"):
        if "FIXME" in para and para.strip().startswith("Files:"):
            # Extract first Files: line for identification
            first_file = ""
            for line in para.split("\n"):
                if line.startswith("Files:"):
                    first_file = line[6:].strip().split()[0] if line[6:].strip() else "?"
                    break
            fixme_stanzas.append(first_file)
    if fixme_stanzas:
        print(f"[WARN] FIXME entries needing attention: {len(fixme_stanzas)}")
        _lim = len(fixme_stanzas) if verbose else 5
        for fs in fixme_stanzas[:_lim]:
            print(f"        Files: {fs} ...")
        if len(fixme_stanzas) > _lim:
            print(f"        ... and {len(fixme_stanzas) - _lim} more")
    else:
        print("[PASS] No FIXME entries in copyright file")

    # Test 16: Versionless or invalid license identifiers
    versionless = []
    for _, lic in stanzas:
        if lic and re.match(r'^CC-BY(?:-SA|-NC|-ND)*$', lic):
            versionless.append(lic)
        elif lic and lic.lower() in ("cc0", "unknown"):
            versionless.append(lic)
    versionless = sorted(set(versionless))
    if versionless:
        print(f"[FAIL] Versionless or invalid license identifiers: "
              f"{len(versionless)}")
        for vl in versionless:
            print(f"        {vl}")
    else:
        print("[PASS] All license identifiers have proper versions")

    # Test 17: Broad glob stanzas that may override specific stanzas
    # In DEP-5, last-match-wins — a broad glob appearing AFTER specific
    # per-file stanzas will override them with potentially wrong info.
    override_issues = []
    seen_specifics = set()  # track files covered by specific stanzas
    for glob_pat, lic in stanzas:
        if glob_pat == "*":
            continue
        if "*" in glob_pat:
            # This is a glob — check if specific stanzas above it
            # already covered files that this glob would match
            overridden = []
            for sp in seen_specifics:
                if fnmatch.fnmatch(sp, glob_pat):
                    overridden.append(sp)
            if overridden:
                override_issues.append((glob_pat, lic, len(overridden)))
        else:
            seen_specifics.add(glob_pat)
    if override_issues:
        print(f"[WARN] Broad globs overriding specific stanzas: "
              f"{len(override_issues)}")
        _lim = len(override_issues) if verbose else 5
        for glob_pat, lic, n in override_issues[:_lim]:
            print(f"        {glob_pat} ({lic}) overrides {n} specific stanza(s)")
        if len(override_issues) > _lim:
            print(f"        ... and {len(override_issues) - _lim} more")
    else:
        print("[PASS] No broad-glob override conflicts")

    # Test 18: Duplicate file declarations across stanzas
    # If the same file path appears in multiple stanzas, the last one wins
    # (DEP-5 last-match-wins).  This is almost always an error — a later
    # stanza with weaker info overriding a correct earlier one.
    file_occurrences = defaultdict(list)  # path → [(index, lic)]
    for i, (glob_pat, lic) in enumerate(stanzas):
        if "*" not in glob_pat:
            file_occurrences[glob_pat].append((i, lic))
    dup_files = {fp: occs for fp, occs in file_occurrences.items()
                 if len(occs) > 1}
    if dup_files:
        print(f"[WARN] Files declared in multiple stanzas: {len(dup_files)}")
        _lim = len(dup_files) if verbose else 5
        for fp in sorted(dup_files)[:_lim]:
            lics = [lic for _, lic in dup_files[fp]]
            print(f"        {fp}  ({' → '.join(lics)})")
        if len(dup_files) > _lim:
            print(f"        ... and {len(dup_files) - _lim} more")
    else:
        print("[PASS] No duplicate file declarations")

    # Test 19: Sanity check — cross-validate scanners
    if has_sanity_check:
        if sanity_real_disc:
            # Files where all scanners disagree
            print(f"[WARN] Scanner cross-validation: "
                  f"{len(sanity_real_disc)} file(s) where all scanners disagree")
            _lim = len(sanity_real_disc) if verbose else 5
            for fp, tool_lics in sanity_real_disc[:_lim]:
                print(f"        {fp}")
                for _t, _l in sorted(tool_lics.items()):
                    _ls = ", ".join(sorted(_l)) or "(none)"
                    print(f"          {_t}:{' ' * max(1, 14 - len(_t))}{_ls}")
            if len(sanity_real_disc) > _lim:
                print(f"        ... and {len(sanity_real_disc) - _lim} more")
        if sanity_consensus:
            # Files where 2-of-3 agree — informational only
            print(f"[INFO] Scanner cross-validation: "
                  f"{len(sanity_consensus)} file(s) resolved by 2-of-3 consensus")
            _lim = len(sanity_consensus) if verbose else 3
            for fp, tool_lics, consensus in sanity_consensus[:_lim]:
                print(f"        {fp}  →  {consensus}")
                for _t, _l in sorted(tool_lics.items()):
                    _ls = ", ".join(sorted(_l)) or "(none)"
                    print(f"          {_t}:{' ' * max(1, 14 - len(_t))}{_ls}")
            if len(sanity_consensus) > _lim:
                print(f"        ... and {len(sanity_consensus) - _lim} more")
        if not sanity_real_disc and not sanity_consensus:
            _tool_names = " and ".join(sanity_tools)
            print(f"[PASS] Scanner cross-validation: "
                  f"{scanner}, {_tool_names} agree")
    else:
        print("[INFO] Scanner cross-validation skipped (no secondary scanner available)")

    # Test 20: Over-broad glob attribution
    over_broad = check_over_broad_attribution()
    if over_broad:
        print(f"[WARN] Over-broad glob attribution: "
              f"{len(over_broad)} holder(s) attributed to more files than detected")
        _lim = len(over_broad) if verbose else 5
        for ob in over_broad[:_lim]:
            globs = " ".join(ob["para"]["files"])
            n_det = len(ob["detected_files"])
            n_gov = len(ob["governed_files"])
            print(f"        {globs}: {ob['holder_raw']}")
            print(f"          detected in {n_det}/{n_gov} files")
        if len(over_broad) > _lim:
            print(f"        ... and {len(over_broad) - _lim} more")
    else:
        print("[PASS] No over-broad glob attribution detected")

    # Test 21: Under-broad glob attribution
    under_broad = check_under_broad_attribution()
    if under_broad:
        print(f"[WARN] Under-broad glob attribution: "
              f"{len(under_broad)} holder(s) detected in files outside their stanza")
        _lim = len(under_broad) if verbose else 5
        for ub in under_broad[:_lim]:
            globs = " ".join(ub["para"]["files"])
            n_unc = len(ub["uncovered_files"])
            print(f"        {globs}: {ub['holder_raw']}")
            print(f"          also detected in {n_unc} uncovered file(s)")
        if len(under_broad) > _lim:
            print(f"        ... and {len(under_broad) - _lim} more")
    else:
        print("[PASS] No under-broad glob attribution detected")

    fixme_ok = not fixme_stanzas
    versionless_ok = not versionless
    override_ok = not override_issues
    dup_ok = not dup_files
    compat_ok = not all_compat
    superfluous_ok = not _superfluous
    over_broad_ok = not over_broad
    under_broad_ok = not under_broad

    if not compat_ok:
        print("VERDICT: BLOCKED — license incompatibility detected. "
              "Must be resolved before archive acceptance.")
    elif (real_count == 0 and not missing_in_copyright and not nonfree_found
            and lic_ok and cov_ok and not stale and dep5_ok
            and not missing_holders and fixme_ok and versionless_ok
            and override_ok and dup_ok and superfluous_ok
            and over_broad_ok and under_broad_ok):
        print("VERDICT: debian/copyright appears complete and accurate.")
    elif real_count < 10 and not nonfree_found and dep5_ok:
        print("VERDICT: Minor issues — mostly acceptable, review flagged items.")
    else:
        print("VERDICT: Needs work — review mismatches and missing licenses above.")
    print()

    if real_mismatches:
        print("-" * W)
        print("DETAILED MISMATCHES:")
        print("-" * W)
        for (det, decl), files in sorted(real_mismatches.items()):
            print(f"\n  Detected: {det}")
            print(f"  Declared: {decl}")
            _lim = len(files) if verbose else 5
            for fp in files[:_lim]:
                print(f"    -> {fp}")
            if len(files) > _lim:
                print(f"    ... and {len(files) - _lim} more")
        print()

    # ── Export full-detail findings ───────────────────────────────────
    if export_file:
        with open(export_file, "w") as ef:
            ef.write("DEBIAN COPYRIGHT REVIEW — FULL EXPORT\n")
            ef.write(f"Generated: {datetime.datetime.now().isoformat()}\n")
            ef.write(f"Source: {os.environ.get('SRCDIR', '.')}\n")
            ef.write(f"Scanner: {scanner}\n")
            ef.write(f"Files scanned: {len(scan_results)}\n")
            ef.write("=" * 78 + "\n\n")

            if real_mismatches:
                ef.write("LICENSE MISMATCHES\n")
                ef.write("-" * 78 + "\n")
                for (det, decl), files in sorted(real_mismatches.items()):
                    ef.write(f"\nDetected: {det}\n")
                    ef.write(f"Declared: {decl}\n")
                    ef.write(f"Files ({len(files)}):\n")
                    for fp in files:
                        ef.write(f"  {fp}\n")
                ef.write("\n")

            if fp_mismatches:
                ef.write("FALSE POSITIVE MISMATCHES\n")
                ef.write("-" * 78 + "\n")
                for cat in sorted(fp_mismatches):
                    items = fp_mismatches[cat]
                    ef.write(f"\nCategory: {cat} ({len(items)} file(s))\n")
                    for fp, det, decl in items:
                        ef.write(f"  {fp}  ({det} vs {decl})\n")
                ef.write("\n")

            if missing_in_copyright:
                ef.write("UNDECLARED LICENSES\n")
                ef.write("-" * 78 + "\n")
                for ml in sorted(missing_in_copyright):
                    ef.write(f"  {ml} (found in {all_scan_licenses[ml]} files)\n")
                ef.write("\n")

            if nonfree_found:
                ef.write("NON-FREE LICENSES\n")
                ef.write("-" * 78 + "\n")
                for nf in nonfree_found:
                    ef.write(f"  {nf}\n")
                ef.write("\n")

            if src_no_lic:
                ef.write(f"SOURCE FILES WITHOUT LICENSE HEADER ({len(src_no_lic)})\n")
                ef.write("-" * 78 + "\n")
                for fp in src_no_lic:
                    ef.write(f"  {fp}\n")
                ef.write("\n")

            if lic_issues:
                ef.write("LICENSE TEXT ISSUES\n")
                ef.write("-" * 78 + "\n")
                for lic, sev, msg in lic_issues:
                    ef.write(f"  [{sev.upper()}] {msg}\n")
                ef.write("\n")

            if src_uncovered:
                ef.write(f"UNCOVERED SOURCE FILES ({len(src_uncovered)})\n")
                ef.write("-" * 78 + "\n")
                for fp, lics in src_uncovered:
                    lic_str = ", ".join(lics) if lics else "(unknown)"
                    ef.write(f"  {fp}  [{lic_str}]\n")
                ef.write("\n")

            if stale:
                ef.write(f"STALE STANZA GLOBS ({len(stale)})\n")
                ef.write("-" * 78 + "\n")
                for glob_pat, lic in stale:
                    ef.write(f"  {glob_pat}  ({lic})\n")
                ef.write("\n")

            if missing_holders or extra_holders:
                ef.write("COPYRIGHT HOLDER MISMATCHES\n")
                ef.write("-" * 78 + "\n")
                for glob_pat, holder in missing_holders:
                    ef.write(f"  + {glob_pat}: {holder}\n")
                for glob_pat, holder in extra_holders:
                    ef.write(f"  - {glob_pat}: {holder}\n")
                ef.write("\n")

            if over_broad:
                ef.write(f"OVER-BROAD GLOB ATTRIBUTION ({len(over_broad)})\n")
                ef.write("-" * 78 + "\n")
                for ob in over_broad:
                    globs = " ".join(ob["para"]["files"])
                    n_det = len(ob["detected_files"])
                    n_gov = len(ob["governed_files"])
                    ef.write(f"  {globs}: {ob['holder_raw']}\n")
                    ef.write(f"    detected in {n_det}/{n_gov} files\n")
                ef.write("\n")

            if under_broad:
                ef.write(f"UNDER-BROAD GLOB ATTRIBUTION ({len(under_broad)})\n")
                ef.write("-" * 78 + "\n")
                for ub in under_broad:
                    globs = " ".join(ub["para"]["files"])
                    n_unc = len(ub["uncovered_files"])
                    n_gov = len(ub["stanza_files"])
                    ef.write(f"  {globs}: {ub['holder_raw']}\n")
                    ef.write(f"    covers {n_gov} files, "
                             f"also detected in {n_unc} more\n")
                ef.write("\n")

            if decopy_issues:
                ef.write(f"DECOPY FINDINGS ({len(decopy_issues)})\n")
                ef.write("-" * 78 + "\n")
                for line in decopy_issues:
                    ef.write(f"  {line}\n")
                ef.write("\n")

            if low_confidence:
                ef.write(f"LOW-CONFIDENCE DETECTIONS ({len(low_confidence)})\n")
                ef.write("-" * 78 + "\n")
                for path, lic, score in low_confidence:
                    ef.write(f"  {path}  ({lic}, score={score})\n")
                ef.write("\n")

            if has_sanity_check:
                if sanity_real_disc:
                    ef.write(f"SCANNER CROSS-VALIDATION — ALL DISAGREE ({len(sanity_real_disc)})\n")
                    ef.write("-" * 78 + "\n")
                    for fp, tool_lics in sanity_real_disc:
                        ef.write(f"  {fp}\n")
                        for _t, _l in sorted(tool_lics.items()):
                            ef.write(f"    {_t}: {', '.join(sorted(_l)) or '(none)'}\n")
                    ef.write("\n")
                if sanity_consensus:
                    ef.write(f"SCANNER CROSS-VALIDATION — 2-OF-3 CONSENSUS ({len(sanity_consensus)})\n")
                    ef.write("-" * 78 + "\n")
                    for fp, tool_lics, consensus in sanity_consensus:
                        ef.write(f"  {fp}  →  {consensus}\n")
                        for _t, _l in sorted(tool_lics.items()):
                            ef.write(f"    {_t}: {', '.join(sorted(_l)) or '(none)'}\n")
                    ef.write("\n")

        print(f"Full findings exported to: {export_file}")

else:
    # ── Standard check report ─────────────────────────────────────────

    # When decopy generated the initial file and we're hardening it,
    # send the check report to stderr so stdout stays clean for the
    # final copyright output (matching normal generate-mode behaviour).
    _saved_stdout = None
    if _decopy_generated:
        _saved_stdout = sys.stdout
        sys.stdout = sys.stderr

    # Run DEP-5 format validation
    dep5_issues = check_dep5_format()
    dep5_errors = [i for i in dep5_issues if i[1] == "error"]
    dep5_warnings = [i for i in dep5_issues if i[1] == "warning"]

    print("=" * W)
    print("SECTION 0: DEP-5 FORMAT VALIDATION")
    print("Structural compliance with machine-readable copyright format")
    print("=" * W)

    if dep5_issues:
        print(f"\n{len(dep5_issues)} format issue(s) found "
              f"({len(dep5_errors)} error(s), {len(dep5_warnings)} warning(s)):\n")
        for lnum, sev, msg, fix_tag in dep5_issues:
            fixable = " [auto-fixable]" if fix_tag else ""
            if lnum > 0:
                print(f"  [{sev.upper()}] Line {lnum}: {msg}{fixable}")
            else:
                print(f"  [{sev.upper()}] {msg}{fixable}")
        print()
    else:
        print("\nDEP-5 format looks correct.\n")

    print("=" * W)
    print("SECTION 1: LICENSE MISMATCHES — NEEDS REVIEW")
    print("Files whose detected license may not match debian/copyright")
    print("=" * W)

    if real_mismatches:
        print(f"\n{real_count} file(s) with mismatched licenses:\n")
        for (det, decl), files in sorted(real_mismatches.items()):
            print(f"  Detected: {det}")
            print(f"  Declared: {decl}")
            for fp in files[:5]:
                print(f"    -> {fp}")
            if len(files) > 5:
                print(f"    ... and {len(files) - 5} more")
            print()
    else:
        print("\nNo actionable mismatches found. debian/copyright looks good!\n")

    FP_LABELS = {
        "license-text-file":
            "LICENSE text files (informational, not compiled code)",
        "autoconf-boilerplate":
            "Autoconf / build-system boilerplate (standard exceptions apply)",
        "doc-embeds-plugin-license":
            "Documentation .rst files embedding upstream plugin license text",
        "project-build-file":
            "Project build files (CMakeLists.txt / .pro) carrying main license",
        "tools-infrastructure":
            "Build infrastructure / tooling files in tools/",
        "metadata-infrastructure":
            "Metadata / infrastructure files (covered by Files: * catch-all)",
    }

    print("=" * W)
    print("SECTION 2: LIKELY FALSE POSITIVES (informational)")
    print("=" * W)

    if fp_mismatches:
        print(f"\n{fp_count} file(s) classified as likely false positives:\n")
        for cat in sorted(fp_mismatches):
            items = fp_mismatches[cat]
            label = FP_LABELS.get(cat, cat)
            print(f"  [{len(items)}] {label}")
            if verbose:
                for fp, det, decl in items[:5]:
                    print(f"       {fp}  ({det} vs {decl})")
                if len(items) > 5:
                    print(f"       ... and {len(items) - 5} more")
        print()
        if not verbose:
            print("  (Use --verbose to see individual files)\n")
    else:
        print("\nNone.\n")

    print("=" * W)
    print("SECTION 3: LICENSE TEXT COMPLETENESS")
    print("=" * W)

    lic_issues = check_license_completeness()
    if lic_issues:
        errors = [i for i in lic_issues if i[1] == "error"]
        warnings = [i for i in lic_issues if i[1] == "warning"]
        print(f"\n{len(lic_issues)} issue(s) with license text blocks:\n")
        for lic, sev, msg in lic_issues:
            print(f"  [{sev.upper()}] {msg}")
        print()
        print("  Tip: licenses not in /usr/share/common-licenses/ must have their")
        print("  full legal text included in debian/copyright. Creative Commons")
        print("  licenses (except CC0-1.0) must include the complete legal code,")
        print("  not just a summary — lintian checks for this specifically.")
    else:
        print("\nAll license text blocks appear complete.")
    print()

    print("=" * W)
    print("SECTION 4: LICENSE COMPATIBILITY")
    print("=" * W)

    all_declared = set(lic for _, lic in stanzas)
    compat_issues = check_license_compatibility(all_declared)
    # Also check detected licenses for conflicts the declared set might miss
    detected_compat = check_license_compatibility(set(all_scan_licenses.keys()))
    all_compat = list(dict.fromkeys(compat_issues + detected_compat))

    if all_compat:
        bar = "!" * W
        print(f"\n{bar}")
        print(f"  LICENSE INCOMPATIBILITY — {len(all_compat)} conflict(s) detected")
        print(bar)
        for issue in all_compat:
            print(f"\n  {issue}")
        print(f"\n{bar}")
        print()

        # Show Apache details if relevant
        if apache_files and any("Apache" in i for i in compat_issues):
            print("  Apache-2.0 files:")
            for fp in apache_files[:10]:
                print(f"    {fp}")
            if len(apache_files) > 10:
                print(f"    ... and {len(apache_files) - 10} more")
            print()
    else:
        print("\nNo compatibility issues detected.\n")

    nonfree_found = {}
    for dep5, count in all_scan_licenses.items():
        if is_nonfree(dep5):
            nonfree_found[dep5] = count
    if nonfree_found:
        total = sum(nonfree_found.values())
        print(f"WARNING: {total} file(s) with non-free/non-DFSG licenses detected:")
        for lic, count in sorted(nonfree_found.items()):
            print(f"    {lic} ({count} file(s))")
        print("  These cannot be in Debian 'main'. Move to 'non-free' or resolve.")
        print()

    print("=" * W)
    print("SECTION 5: LICENSES FOUND IN SOURCE (detected by scanner)")
    print("=" * W)
    print()
    for lic, count in sorted(all_scan_licenses.items(), key=lambda x: -x[1]):
        declared_match = any(norm_cmp(lic) == norm_cmp(d) for _, d in stanzas)
        marker = " " if declared_match else "!"
        print(f"  {marker} {lic:30s} {count:5d} file(s)"
              + ("" if declared_match else "  <-- NOT in debian/copyright"))
    print()

    print("=" * W)
    print("SECTION 6: STANZA COVERAGE ANALYSIS")
    print("=" * W)

    uncovered, stale = check_coverage()
    src_uncovered = [(fp, lics) for fp, lics in uncovered
                     if Path(fp).suffix.lower() in SOURCE_EXTS]
    data_uncovered = [(fp, lics) for fp, lics in uncovered
                      if Path(fp).suffix.lower() not in SOURCE_EXTS]

    if src_uncovered:
        print(f"\n{len(src_uncovered)} source file(s) not matched by any Files: stanza:")
        print("  (These are only covered by the catch-all * glob)\n")
        for fp, lics in src_uncovered[:20]:
            lic_str = ", ".join(lics) if lics else "(no license detected)"
            print(f"    {fp}")
            print(f"      detected: {lic_str}")
        if len(src_uncovered) > 20:
            print(f"    ... and {len(src_uncovered) - 20} more")
        print()
    else:
        print("\nAll source files are matched by a specific Files: stanza.")
        print()

    if data_uncovered:
        print(f"  ({len(data_uncovered)} non-source files also only matched by catch-all,")
        print(f"   which is normal for data files, images, configs, etc.)")
        if verbose:
            for fp, lics in data_uncovered[:10]:
                lic_str = ", ".join(lics) if lics else "(none)"
                print(f"    {fp}  [{lic_str}]")
            if len(data_uncovered) > 10:
                print(f"    ... and {len(data_uncovered) - 10} more")
        print()

    if stale:
        print(f"{len(stale)} Files: glob(s) in debian/copyright match no actual file:")
        print("  (These may be stale entries from removed/renamed files)\n")
        for glob_pat, lic in stale:
            print(f"    {glob_pat}  (License: {lic})")
        print()
    elif stanzas:
        print("All Files: globs match at least one file in the source tree.")
        print()

    _superfluous = check_superfluous()
    if _superfluous:
        entirely = [s for s in _superfluous if s["remove_entirely"]]
        partially = [s for s in _superfluous if not s["remove_entirely"]]
        if entirely:
            print(f"{len(entirely)} entirely superfluous stanza(s) found:")
            print("  (These can be removed without changing effective coverage)\n")
            for sup in entirely:
                ps = sup["stanza"]
                files_preview = " ".join(ps["files"][:3])
                if len(ps["files"]) > 3:
                    files_preview += f" ... (+{len(ps['files'])-3} more)"
                print(f"    Files: {files_preview}")
                print(f"      Reason: {sup['reason']}")
            print()
        if partially:
            print(f"{len(partially)} stanza(s) with superfluous patterns:")
            print("  (Individual patterns that match no files)\n")
            for sup in partially:
                ps = sup["stanza"]
                files_preview = " ".join(ps["files"][:3])
                if len(ps["files"]) > 3:
                    files_preview += f" ... (+{len(ps['files'])-3} more)"
                print(f"    Files: {files_preview}")
                print(f"      Stale: {', '.join(sup['stale_patterns'][:5])}"
                      f"{'...' if len(sup['stale_patterns']) > 5 else ''}")
            print()
        print("  TIP: Run with --fix to remove superfluous patterns/stanzas.\n")
    elif stanzas:
        print("No superfluous stanzas or patterns detected.")
        print()

    # Check for missing debian/* stanza (not applicable for native packages)
    has_debian_glob = any(g in ("debian/*", "debian/**") for g, _ in stanzas)
    if stanzas and not has_debian_glob and not is_native:
        print("  WARNING: No debian/* stanza found in debian/copyright.")
        print("  Packaging files (debian/*) should have their own Files stanza")
        print("  with the packager's copyright. Use --fix to add one.")
        print()

    print("=" * W)
    print("SECTION 7: FILES WITH NO DETECTED LICENSE")
    print("=" * W)

    if no_license:
        src_no_lic = [f for f in no_license
                      if Path(f).suffix.lower() in SOURCE_EXTS]
        if src_no_lic:
            print(f"\n{len(src_no_lic)} source file(s) with no license header:")
            for fp in src_no_lic[:20]:
                print(f"    {fp}")
            if len(src_no_lic) > 20:
                print(f"    ... and {len(src_no_lic) - 20} more")
        else:
            print(f"\n{len(no_license)} file(s) with no license header"
                  " (all non-source).")
        print(f"  Total files without detected license: {len(no_license)}")
        print("  (Data files, images, configs, etc. are typically fine"
              " without headers)")
    else:
        print("\nAll files have detectable license headers.")
    print()

    # Copyright holder audit
    missing_holders, extra_holders, _, _ = check_copyright_holders()
    print("=" * W)
    print("SECTION 8: COPYRIGHT HOLDER ACCURACY")
    print("=" * W)

    if missing_holders or extra_holders:
        if missing_holders:
            print(f"\n{len(missing_holders)} copyright holder(s) detected in source but NOT declared:\n")
            for glob_pat, holder in missing_holders[:20]:
                print(f"    {glob_pat}")
                print(f"      detected: {holder}")
            if len(missing_holders) > 20:
                print(f"    ... and {len(missing_holders) - 20} more")
            print()
        if extra_holders:
            print(f"{len(extra_holders)} copyright holder(s) declared but NOT found in source:\n")
            for glob_pat, holder in extra_holders[:20]:
                print(f"    {glob_pat}")
                print(f"      declared: {holder}")
            if len(extra_holders) > 20:
                print(f"    ... and {len(extra_holders) - 20} more")
            print()
        print("  Tip: Missing holders should be added to the matching Copyright:")
        print("  field. Extra holders may be from removed code — verify before")
        print("  removing, as upstream attribution requirements may still apply.")
    else:
        print("\nDeclared copyright holders match detected holders.")
    print()

    # Over-broad glob attribution
    over_broad = check_over_broad_attribution()
    print("=" * W)
    print("SECTION 8b: OVER-BROAD GLOB ATTRIBUTION")
    print("=" * W)

    if over_broad:
        print(f"\n{len(over_broad)} holder(s) attributed to more files than detected:\n")
        for ob in over_broad:
            globs = " ".join(ob["para"]["files"])
            n_det = len(ob["detected_files"])
            n_gov = len(ob["governed_files"])
            print(f"    {globs}")
            print(f"      {ob['holder_raw']}  (detected in {n_det}/{n_gov} matched files)")
            if verbose:
                det_set = set(ob["detected_files"])
                extra = sorted(set(ob["governed_files"]) - det_set)
                for fp in extra[:10]:
                    print(f"        NOT detected: {fp}")
                if len(extra) > 10:
                    print(f"        ... and {len(extra) - 10} more")
        print()
        print("  Tip: Use --fix to narrow over-broad globs so each holder")
        print("  is only attributed to files where they were actually detected.")
    else:
        print("\nNo over-broad glob attribution detected.")
    print()

    # Under-broad glob attribution
    under_broad = check_under_broad_attribution()
    print("=" * W)
    print("SECTION 8c: UNDER-BROAD GLOB ATTRIBUTION")
    print("=" * W)

    if under_broad:
        print(f"\n{len(under_broad)} holder(s) detected in files outside their stanza:\n")
        for ub in under_broad:
            globs = " ".join(ub["para"]["files"])
            n_unc = len(ub["uncovered_files"])
            n_gov = len(ub["stanza_files"])
            print(f"    {globs}")
            print(f"      {ub['holder_raw']}  (covers {n_gov} files, "
                  f"but also detected in {n_unc} more)")
            if verbose:
                for fp in ub["uncovered_files"][:10]:
                    print(f"        NOT covered: {fp}")
                if len(ub["uncovered_files"]) > 10:
                    print(f"        ... and {len(ub['uncovered_files']) - 10} more")
        print()
        print("  Tip: Use --fix to widen stanza globs so each holder is")
        print("  attributed to all files where they were actually detected.")
    else:
        print("\nNo under-broad glob attribution detected.")
    print()

    # Stanza consolidation opportunities
    consol_groups = check_consolidation()
    print("=" * W)
    print("SECTION 9: STANZA CONSOLIDATION")
    print("=" * W)

    if consol_groups:
        total_saving = sum(len(g["stanzas"]) - 1 for g in consol_groups)
        print(f"\n{len(consol_groups)} group(s) of stanzas can be consolidated "
              f"({total_saving} stanza(s) can be eliminated):\n")
        for grp in consol_groups:
            n = len(grp["stanzas"])
            f_count = len(grp["merged_files"])
            print(f"  {grp['license']} — {n} stanzas → 1 ({f_count} files)")
            print(f"    Copyright: {', '.join(grp['merged_cops'])}")
            for ps in grp["stanzas"]:
                print(f"      • {' '.join(ps['files'][:3])}"
                      + (f" ... (+{len(ps['files'])-3} more)" if len(ps['files']) > 3 else ""))
        print()
        print("  Tip: Use --fix to interactively merge these stanzas.")
    else:
        print("\nNo consolidation opportunities found.")
    print()

    # Decopy findings (if decopy was run)
    if decopy_issues:
        print("=" * W)
        print("SECTION 10: DECOPY FINDINGS")
        print("=" * W)
        print(f"\n{len(decopy_issues)} issue(s) reported by decopy:\n")
        for line in decopy_issues[:20]:
            print(f"  {line}")
        if len(decopy_issues) > 20:
            print(f"  ... and {len(decopy_issues) - 20} more")
        print()

    # Scanner cross-validation (sanity check)
    if has_sanity_check:
        print("=" * W)
        print("SECTION 11: SCANNER CROSS-VALIDATION (sanity check)")
        _all_tools = [scanner] + sanity_tools
        print(f"{' vs '.join(_all_tools)} — files where tools disagree on license")
        print("=" * W)
        if sanity_real_disc:
            print(f"\n{len(sanity_real_disc)} file(s) where ALL scanners disagree:\n")
            for fp, tool_lics in sanity_real_disc:
                print(f"  {fp}")
                for _t, _l in sorted(tool_lics.items()):
                    print(f"    {_t}: {', '.join(sorted(_l)) or '(none)'}")
            print()
            print("  These files may warrant manual inspection.")
        if sanity_consensus:
            print(f"\n{len(sanity_consensus)} file(s) resolved by 2-of-3 consensus:\n")
            for fp, tool_lics, consensus in sanity_consensus:
                print(f"  {fp}  →  {consensus}")
                for _t, _l in sorted(tool_lics.items()):
                    print(f"    {_t}: {', '.join(sorted(_l)) or '(none)'}")
            print()
        if not sanity_real_disc and not sanity_consensus:
            print(f"\nNo discrepancies — all scanners agree on all files.")
        print()

    # Low-confidence detections (scancode only)
    if low_confidence:
        print("=" * W)
        print("SECTION 12: LOW-CONFIDENCE DETECTIONS (skipped)")
        print("=" * W)
        print(f"\n{len(low_confidence)} detection(s) below confidence threshold "
              f"({MIN_CONFIDENCE}%):\n")
        for path, lic, score in low_confidence[:20]:
            print(f"  {path}  ({lic}, score={score})")
        if len(low_confidence) > 20:
            print(f"  ... and {len(low_confidence) - 20} more")
        print()

    # Summary
    print("=" * W)
    print("SUMMARY")
    print("=" * W)
    print(f"  Scanner:                               {scanner}"
          + (" (SPDX)" if scanner == "scancode" else " (heuristic)"))
    print(f"  Files scanned:                         {len(scan_results)}")
    print(f"  Unique licenses detected:              {len(all_scan_licenses)}")
    print(f"  Licenses declared in debian/copyright: {len(all_declared)}")
    print(f"  File stanzas in debian/copyright:      {len(stanzas)}")
    print(f"  Mismatches needing review:             {real_count}")
    print(f"  Likely false positives:                {fp_count}")
    print(f"  Files with no license header:          {len(no_license)}")
    print(f"  Source files uncovered by stanzas:     {len(src_uncovered)}")
    print(f"  Stale stanza globs:                    {len(stale)}")
    print(f"  DEP-5 format errors:                   {len(dep5_errors)}")
    print(f"  DEP-5 format warnings:                 {len(dep5_warnings)}")
    consol_saving = sum(len(g['stanzas']) - 1 for g in consol_groups) if consol_groups else 0
    if consol_groups:
        print(f"  Stanzas consolidatable:                {consol_saving} (in {len(consol_groups)} group(s))")
    if over_broad:
        print(f"  Over-broad glob attributions:          {len(over_broad)}")
    if under_broad:
        print(f"  Under-broad glob attributions:         {len(under_broad)}")
    if has_sanity_check:
        if sanity_real_disc:
            print(f"  Scanner cross-validation (all disagree): {len(sanity_real_disc)}")
        if sanity_consensus:
            print(f"  Scanner cross-validation (2-of-3 ok):  {len(sanity_consensus)}")

    _superfluous_count = len(_superfluous) if '_superfluous' in dir() else 0
    _over_broad_count = len(over_broad) if 'over_broad' in dir() else 0
    _under_broad_count = len(under_broad) if 'under_broad' in dir() else 0
    total_issues = (real_count + len(src_uncovered) + len(stale)
                    + len(dep5_errors) + _superfluous_count
                    + _over_broad_count + _under_broad_count)
    if total_issues == 0:
        print("\n  RESULT: debian/copyright appears complete.")
    else:
        print(f"\n  RESULT: {total_issues} item(s) may need attention.")
    print()

    # Offer to enter fix mode if issues were found and --fix wasn't given
    fixable_dep5_count = sum(1 for i in dep5_issues if i[3] is not None)
    has_fixable = total_issues > 0 or fixable_dep5_count > 0 or consol_saving > 0
    if not fix_mode and has_fixable and copyright_file:
        if yes_mode:
            fix_mode = True
        elif sys.stderr.isatty():
            try:
                _offer_tty = open("/dev/tty", "r")
                sys.stderr.write("  Run interactive fix mode now? [y/N/A(yes to all)] ")
                sys.stderr.flush()
                offer_ans = _offer_tty.readline().rstrip("\n").strip().lower()
                _offer_tty.close()
                if offer_ans in ("y", "yes"):
                    fix_mode = True
                elif offer_ans in ("a", "all"):
                    fix_mode = True
                    yes_mode = True
            except OSError:
                pass

    # ══════════════════════════════════════════════════════════════════
    # Interactive fix mode
    # ══════════════════════════════════════════════════════════════════
    if fix_mode and has_fixable and copyright_file:
        print("=" * W)
        print("INTERACTIVE FIX MODE")
        print("=" * W)
        print()
        print("  (In each prompt: A = yes to all remaining  |  X = no to all remaining)\n")

        # input() won't work — stdin is the heredoc.
        # Read interactive answers from /dev/tty instead.
        import atexit
        try:
            _tty = open("/dev/tty", "r")
            atexit.register(_tty.close)
        except OSError:
            print("ERROR: Cannot open /dev/tty for interactive input.", file=sys.stderr)
            print("  --fix requires an interactive terminal.", file=sys.stderr)
            sys.exit(1)

        # Force unbuffered output so prompts appear immediately
        import functools
        print = functools.partial(print, flush=True)

        _batch_ans = [None]  # None = no all-choice; True = yes-to-all; False = no-to-all

        def ask(prompt, batch=False):
            """Prompt the user and read from /dev/tty.

            When batch=True, the response 'A' sets yes-to-all for all future
            batch prompts, and 'X' sets no-to-all.
            """
            if yes_mode:
                sys.stdout.write(prompt + "y (--yes)\n")
                sys.stdout.flush()
                return "y"
            if batch:
                if _batch_ans[0] is True:
                    sys.stdout.write(prompt + "y (yes to all)\n")
                    sys.stdout.flush()
                    return "y"
                if _batch_ans[0] is False:
                    sys.stdout.write(prompt + "n (no to all)\n")
                    sys.stdout.flush()
                    return "n"
            sys.stdout.write(prompt)
            sys.stdout.flush()
            try:
                ans = _tty.readline().rstrip("\n")
            except EOFError:
                return ""
            if batch:
                al = ans.strip().lower()
                if al in ("a", "all"):
                    _batch_ans[0] = True
                    return "y"
                if al in ("x", "none"):
                    _batch_ans[0] = False
                    return "n"
            return ans

        # Read the current copyright file
        with open(copyright_file, encoding="utf-8") as f:
            original_text = f.read()
        working_text = original_text
        changes_made = 0

        # Helper: build compact globs from a list of file paths
        def make_fix_globs(paths, no_glob_dirs=frozenset()):
            """Group files by directory and produce compact globs.
            Uses dir/* only when multiple files share a directory.
            Directories in no_glob_dirs are always listed individually."""
            dirs = defaultdict(list)
            for p in paths:
                p = p.lstrip("./")
                d = str(Path(p).parent)
                dirs[d].append(p)
            globs = []
            for d, files in sorted(dirs.items()):
                if d == ".":
                    globs.extend(sorted(files))
                elif len(files) > 1 and d not in no_glob_dirs:
                    globs.append(d + "/*")
                else:
                    globs.extend(sorted(files))
            return globs

        # Helper: find the copyright holders for a set of files from scan
        def get_copyrights_for_files(filepaths):
            cops = set()
            fp_set = set(filepaths)
            for r in scan_results:
                if r["path"] in fp_set or r["path"].lstrip("./") in fp_set:
                    for c in r.get("copyrights", []):
                        if c:
                            cops.add(c)
            return sorted(cops)

        # Helper: find insertion point (before the first standalone License: block)
        def find_stanza_insert_pos(text):
            """Find the byte offset to insert new File stanzas.
            Inserts before the first standalone License: paragraph."""
            best_pos = len(text)  # default: end of file
            for m in re.finditer(r'\n\nLicense: ', text):
                # Check this is standalone (no Files: in this paragraph)
                para_start = m.start() + 2
                para_end = text.find("\n\n", para_start)
                if para_end == -1:
                    para_end = len(text)
                para = text[para_start:para_end]
                if not para.startswith("Files:") and "\nFiles:" not in para:
                    best_pos = m.start() + 1  # after the first \n
                    break
            return best_pos

        # ── Fix 0: DEP-5 format fixes ───────────────────────────────
        fixable_dep5 = [i for i in dep5_issues if i[3] is not None]
        if fixable_dep5:
            print(f"{len(fixable_dep5)} auto-fixable DEP-5 format issue(s):\n")
            for lnum, sev, msg, fix_tag in fixable_dep5:
                prefix = f"Line {lnum}: " if lnum > 0 else ""
                print(f"  [{sev.upper()}] {prefix}{msg}")

            answer = ask("\n  Apply DEP-5 format fixes? [y/N/A/X] ", batch=True).strip().lower()
            if answer in ("y", "yes"):
                working_text, fix_descs = fix_dep5_format(working_text)
                for desc in fix_descs:
                    print(f"  ✓ {desc}")
                changes_made += 1
                print()
            else:
                print("  ✗ Skipped.\n")

        # ── Fix 0b: Populate header stubs ────────────────────────────
        # Decopy leaves Source: TODO and may omit Upstream-Name/Contact.
        # Try to infer from the source tree (debian/control, setup.py,
        # pyproject.toml, package.json, etc.).
        _hdr_paras = working_text.split("\n\n")
        if _hdr_paras:
            _hdr = _hdr_paras[0]
            _hdr_changed = False

            # Infer upstream name/contact/source from multiple sources.
            # Priority for Upstream-Contact:
            #   1. GitHub/GitLab API (if Homepage is a forge URL)
            #   2. Package metadata (setup.py, pyproject.toml, package.json, etc.)
            #   3. Maintainer from debian/control (last resort — usually packager)
            _upstream_name = ""
            _upstream_contact = ""
            _upstream_source = ""
            _packager_contact = ""   # debian/control Maintainer (packager)

            for _ctrl_path in ("debian/control",):
                if os.path.isfile(_ctrl_path):
                    with open(_ctrl_path, encoding="utf-8",
                              errors="replace") as _cf:
                        for _cl in _cf:
                            _cl = _cl.rstrip("\n")
                            if _cl.startswith("Source:"):
                                _upstream_name = _upstream_name or \
                                    _cl[7:].strip()
                            if _cl.startswith("Homepage:"):
                                _upstream_source = _upstream_source or \
                                    _cl[9:].strip()
                            if _cl.startswith("Maintainer:"):
                                _packager_contact = _packager_contact \
                                    or _cl[11:].strip()

            # Fallback: directory name
            if not _upstream_name:
                _upstream_name = os.path.basename(
                    os.environ.get("SRCDIR", "."))

            # ── Infer upstream contact from forge API ────────────────
            _gh_author_name = ""
            _gh_author_email = ""
            _gh_created_year = ""
            _gh_m = re.match(
                r'https?://github\.com/([^/]+)/([^/]+?)'
                r'(?:\.git)?(?:/[^#]*)?(?:#.*)?$',
                _upstream_source) if _upstream_source else None
            if _gh_m:
                _gh_owner, _gh_repo = _gh_m.group(1), _gh_m.group(2)
                try:
                    _gh_url = (f"https://api.github.com/repos/"
                               f"{_gh_owner}/{_gh_repo}")
                    _gh_req = Request(_gh_url)
                    _gh_req.add_header(
                        "Accept", "application/vnd.github.v3+json")
                    with urlopen(_gh_req, timeout=10) as _gh_resp:
                        _gh_data = json.loads(_gh_resp.read())
                    _gh_created_year = _gh_data.get(
                        "created_at", "")[:4]
                    _gh_owner_url = _gh_data.get(
                        "owner", {}).get("url", "")
                    if _gh_owner_url:
                        _gh_req2 = Request(_gh_owner_url)
                        _gh_req2.add_header(
                            "Accept", "application/vnd.github.v3+json")
                        with urlopen(_gh_req2,
                                     timeout=10) as _gh_resp2:
                            _gh_udata = json.loads(
                                _gh_resp2.read())
                        _gh_author_name = (
                            _gh_udata.get("name")
                            or _gh_udata.get("login", ""))
                        _gh_author_email = (
                            _gh_udata.get("email") or "")
                    if not _gh_author_name:
                        _gh_author_name = _gh_owner
                except Exception:
                    pass

            if _gh_author_name:
                if _gh_author_email:
                    _upstream_contact = (
                        f"{_gh_author_name} <{_gh_author_email}>")
                else:
                    _upstream_contact = _gh_author_name

            # ── Infer from package metadata files ────────────────────
            if not _upstream_contact:
                # setup.py: author = "..."
                # setup.cfg: author = ... (INI-style, no quotes)
                for _meta in ("setup.py", "setup.cfg"):
                    if os.path.isfile(_meta):
                        try:
                            with open(_meta, encoding="utf-8",
                                      errors="replace") as _mf:
                                _mtxt = _mf.read()
                            # Try quoted (setup.py) then unquoted (setup.cfg)
                            _am = re.search(
                                r'''(?:^|\n)\s*author\s*=\s*['"](.+?)['"]''',
                                _mtxt)
                            if not _am:
                                _am = re.search(
                                    r'(?:^|\n)\s*author\s*=\s*(.+)',
                                    _mtxt)
                            _em = re.search(
                                r'''(?:^|\n)\s*author_email\s*=\s*['"](.+?)['"]''',
                                _mtxt)
                            if not _em:
                                _em = re.search(
                                    r'(?:^|\n)\s*author_email\s*=\s*(.+)',
                                    _mtxt)
                            if _am:
                                _upstream_contact = _am.group(1).strip()
                                if _em:
                                    _upstream_contact += (
                                        f" <{_em.group(1).strip()}>")
                                break
                        except Exception:
                            pass
                # pyproject.toml authors (inline array or [[project.authors]])
                if not _upstream_contact and os.path.isfile(
                        "pyproject.toml"):
                    try:
                        with open("pyproject.toml", encoding="utf-8",
                                  errors="replace") as _pf:
                            _ptxt = _pf.read()
                        # Try inline array: authors = [{name = "...", ...}]
                        _pm = re.search(
                            r'authors\s*=\s*\[\s*\{[^}]*'
                            r'name\s*=\s*"(.+?)"',
                            _ptxt)
                        if _pm:
                            _upstream_contact = _pm.group(1)
                            _pe = re.search(
                                r'authors\s*=\s*\[\s*\{[^}]*'
                                r'email\s*=\s*"(.+?)"',
                                _ptxt)
                            if _pe:
                                _upstream_contact += (
                                    f" <{_pe.group(1)}>")
                        else:
                            # Try table-array: [[project.authors]]
                            _pm = re.search(
                                r'\[\[project\.authors\]\]\s*\n'
                                r'\s*name\s*=\s*"(.+?)"',
                                _ptxt)
                            if _pm:
                                _upstream_contact = _pm.group(1)
                                _pe = re.search(
                                    r'\[\[project\.authors\]\]'
                                    r'\s*\n[^\[]*?'
                                    r'email\s*=\s*"(.+?)"',
                                    _ptxt)
                                if _pe:
                                    _upstream_contact += (
                                        f" <{_pe.group(1)}>")
                    except Exception:
                        pass
                # package.json "author"
                if not _upstream_contact and os.path.isfile(
                        "package.json"):
                    try:
                        with open("package.json", encoding="utf-8",
                                  errors="replace") as _jf:
                            _jdata = json.loads(_jf.read())
                        _jauth = _jdata.get("author")
                        if isinstance(_jauth, dict):
                            _upstream_contact = _jauth.get("name", "")
                            if _jauth.get("email"):
                                _upstream_contact += (
                                    f" <{_jauth['email']}>")
                        elif isinstance(_jauth, str) and _jauth:
                            _upstream_contact = _jauth
                    except Exception:
                        pass
                # CMakeLists.txt project(... VERSION ...) — author not
                # typically there, but DESCRIPTION might help; skip.

            # ── Last resort: packager from debian/control ────────────
            if not _upstream_contact:
                _upstream_contact = _packager_contact

            # Fill in Source: TODO or FIXME
            if re.search(r'^Source:\s*(TODO|FIXME)\s*$', _hdr,
                         re.MULTILINE):
                if _upstream_source:
                    _hdr = re.sub(
                        r'^Source:\s*(TODO|FIXME)\s*$',
                        f'Source: {_upstream_source}',
                        _hdr, flags=re.MULTILINE)
                    _hdr_changed = True
                    print(f"  Filled Source: {_upstream_source}")

            # Fill in Upstream-Name: FIXME
            if re.search(r'^Upstream-Name:\s*FIXME\s*$', _hdr,
                         re.MULTILINE):
                if _upstream_name:
                    _hdr = re.sub(
                        r'^Upstream-Name:\s*FIXME\s*$',
                        f'Upstream-Name: {_upstream_name}',
                        _hdr, flags=re.MULTILINE)
                    _hdr_changed = True
                    print(f"  Filled Upstream-Name: {_upstream_name}")

            # Fill in Upstream-Contact: FIXME
            if re.search(r'^Upstream-Contact:\s*FIXME\s*$', _hdr,
                         re.MULTILINE):
                if _upstream_contact:
                    _hdr = re.sub(
                        r'^Upstream-Contact:\s*FIXME\s*$',
                        f'Upstream-Contact: {_upstream_contact}',
                        _hdr, flags=re.MULTILINE)
                    _hdr_changed = True
                    print(
                        f"  Filled Upstream-Contact: "
                        f"{_upstream_contact}")

            # Add missing Upstream-Name
            if "Upstream-Name:" not in _hdr and _upstream_name:
                _hdr = re.sub(
                    r'^(Format:[^\n]*)',
                    rf'\1\nUpstream-Name: {_upstream_name}',
                    _hdr, count=1)
                _hdr_changed = True
                print(f"  Added Upstream-Name: {_upstream_name}")

            # Add missing Upstream-Contact
            if "Upstream-Contact:" not in _hdr and _upstream_contact:
                if "Upstream-Name:" in _hdr:
                    _hdr = re.sub(
                        r'^(Upstream-Name:[^\n]*)',
                        rf'\1\nUpstream-Contact: {_upstream_contact}',
                        _hdr, count=1, flags=re.MULTILINE)
                else:
                    _hdr = re.sub(
                        r'^(Format:[^\n]*)',
                        rf'\1\nUpstream-Contact: {_upstream_contact}',
                        _hdr, count=1)
                _hdr_changed = True
                print(f"  Added Upstream-Contact: {_upstream_contact}")

            if _hdr_changed:
                _hdr_paras[0] = _hdr
                working_text = "\n\n".join(_hdr_paras)
                changes_made += 1
                print()

        # ── Fix 0c: Remove FSF boilerplate copyright holders ─────────
        # Scanners (and decopy) attribute "Free Software Foundation" as a
        # copyright holder on GPL-family files because the FSF holds the
        # copyright on the GPL *text itself*, not on the project code.
        # Strip these false attributions from Files stanzas whose license
        # is GPL/LGPL/AGPL/GFDL.
        _fsf_re = re.compile(r'free\s+software\s+foundation', re.IGNORECASE)
        _gpl_lic_re = re.compile(r'\b(GPL|LGPL|AGPL|GFDL)\b', re.IGNORECASE)
        _0c_paras = working_text.split("\n\n")
        _0c_count = 0
        for _pi, _para in enumerate(_0c_paras):
            # Only touch Files stanzas (not header, not standalone License blocks)
            if not re.match(r'^\s*Files:', _para):
                continue
            # Check if the stanza's license is GPL-family
            _lic_m = re.search(r'^License:\s*(.+)', _para, re.MULTILINE)
            if not _lic_m or not _gpl_lic_re.search(_lic_m.group(1)):
                continue
            # Find and filter Copyright lines
            lines = _para.split("\n")
            # Collect all copyright holder values from this stanza
            cop_values = []
            cop_start = None  # index of first copyright line
            cop_end = None    # index after last copyright line
            in_copyright = False
            for li, line in enumerate(lines):
                if line.startswith("Copyright:"):
                    in_copyright = True
                    if cop_start is None:
                        cop_start = li
                    val = line[10:].strip()
                    if val:
                        cop_values.append(val)
                    cop_end = li + 1
                elif in_copyright and line.startswith("           "):
                    val = line.strip()
                    if val:
                        cop_values.append(val)
                    cop_end = li + 1
                else:
                    in_copyright = False
            if cop_start is None:
                continue
            # Filter out FSF boilerplate holders
            filtered = [v for v in cop_values if not _fsf_re.search(v)]
            if len(filtered) == len(cop_values):
                continue  # nothing to remove
            if not filtered:
                continue  # don't remove ALL holders
            # Rebuild copyright block
            cop_lines = [f"Copyright: {filtered[0]}"]
            for fv in filtered[1:]:
                cop_lines.append(f"           {fv}")
            new_lines = lines[:cop_start] + cop_lines + lines[cop_end:]
            _0c_paras[_pi] = "\n".join(new_lines)
            _0c_count += 1
        if _0c_count:
            working_text = "\n\n".join(_0c_paras)
            changes_made += 1
            print(f"  Removed FSF boilerplate copyright from "
                  f"{_0c_count} GPL-family stanza(s).\n")

        # ── Fix 0c3: Remove implausible copyright holders ────────────
        # Decopy (and sometimes scanners) attribute code fragments,
        # IPTC metadata descriptions, or other non-person strings as
        # copyright holders.  Strip anything that fails the plausibility
        # check from every Files stanza's Copyright field.
        _0c3_paras = working_text.split("\n\n")
        _0c3_count = 0
        for _pi, _para in enumerate(_0c3_paras):
            if not re.match(r'^\s*Files:', _para):
                continue
            lines = _para.split("\n")
            cop_values = []
            cop_start = None
            cop_end = None
            in_copyright = False
            for li, line in enumerate(lines):
                if line.startswith("Copyright:"):
                    in_copyright = True
                    if cop_start is None:
                        cop_start = li
                    val = line[10:].strip()
                    if val:
                        cop_values.append(val)
                    cop_end = li + 1
                elif in_copyright and line.startswith(" ") and line.strip():
                    cop_values.append(line.strip())
                    cop_end = li + 1
                else:
                    in_copyright = False
            if cop_start is None or not cop_values:
                continue
            filtered = [v for v in cop_values if _is_plausible_copyright(v)]
            if len(filtered) == len(cop_values):
                continue  # nothing to remove
            if not filtered:
                filtered = ["FIXME"]
            cop_lines = [f"Copyright: {filtered[0]}"]
            for fv in filtered[1:]:
                cop_lines.append(f"           {fv}")
            new_lines = lines[:cop_start] + cop_lines + lines[cop_end:]
            _0c3_paras[_pi] = "\n".join(new_lines)
            _0c3_count += 1
        if _0c3_count:
            working_text = "\n\n".join(_0c3_paras)
            changes_made += 1
            print(f"  Removed {_0c3_count} implausible copyright holder(s)"
                  f" (code fragments, metadata text, etc.).\n")

        # ── Fix 0c4: Remove packager-as-copyright from upstream stanzas
        # Previous runs may have incorrectly used the packager/maintainer
        # identity as a copyright fallback for upstream file stanzas.
        # Only Files: * (catch-all) and Files: debian/* should carry the
        # packager copyright.
        if packager_name:
            _packager_re = re.compile(
                re.escape(packager_name), re.IGNORECASE)
            _0c4_paras = working_text.split("\n\n")
            _0c4_count = 0
            for _pi, _para in enumerate(_0c4_paras):
                if not re.match(r'^\s*Files:', _para):
                    continue
                # Parse file patterns
                _s_files = []
                for _line in _para.split("\n"):
                    if _line.startswith("Files:"):
                        _s_files.extend(_line[6:].strip().split())
                    elif _s_files and _line.startswith("       "):
                        _s_files.extend(_line.strip().split())
                    elif _s_files:
                        break
                # Skip catch-all and debian/* stanzas
                if _s_files == ["*"]:
                    continue
                if any(p.startswith("debian/") for p in _s_files):
                    continue
                # Check if Copyright field mentions the packager
                lines = _para.split("\n")
                cop_start = cop_end = None
                for _li, _line in enumerate(lines):
                    if _line.startswith("Copyright:"):
                        cop_start = _li
                    elif cop_start is not None and re.match(
                            r'^\s{2,}', _line):
                        pass  # continuation
                    elif cop_start is not None and cop_end is None:
                        cop_end = _li
                if cop_start is not None and cop_end is None:
                    cop_end = len(lines)
                if cop_start is None:
                    continue
                cop_lines = lines[cop_start:cop_end]
                # Filter out lines containing the packager name
                new_cop = []
                for cl in cop_lines:
                    if _packager_re.search(cl):
                        continue
                    new_cop.append(cl)
                if len(new_cop) == len(cop_lines):
                    continue  # packager not found
                if not new_cop:
                    new_cop = ["Copyright: FIXME"]
                new_lines = lines[:cop_start] + new_cop + lines[cop_end:]
                _0c4_paras[_pi] = "\n".join(new_lines)
                _0c4_count += 1
            if _0c4_count:
                working_text = "\n\n".join(_0c4_paras)
                changes_made += 1
                print(f"  Removed packager copyright from "
                      f"{_0c4_count} upstream stanza(s).\n")

        # ── Fix 0c5: Trim catch-all Copyright to AUTHORS entries ─────
        # The Files: * stanza should list only project-level copyright
        # holders (those in AUTHORS / CONTRIBUTORS), not every author
        # detected in every file.  Specific stanzas handle per-file
        # attribution.  This fix reads the AUTHORS file, builds a set
        # of known project authors, and removes non-matching holders
        # from the catch-all.
        _authors_path = None
        for _af in ("AUTHORS", "AUTHORS.md", "CONTRIBUTORS",
                     "CONTRIBUTORS.md"):
            if os.path.isfile(_af):
                _authors_path = _af
                break
        if _authors_path:
            _0c5_known_emails = set()
            _0c5_known_names = set()
            with open(_authors_path, encoding="utf-8",
                      errors="replace") as _af:
                for _aline in _af:
                    _aline = _aline.strip()
                    if not _aline or _aline.startswith(("-", "=", "#")):
                        continue
                    # Strip trailing role (": Developer, ...")
                    _aline = re.sub(r'\s*:.*$', '', _aline)
                    # Strip leading version tags ("8.2.0 :")
                    _aline = re.sub(r'^[\d.]+\s*:\s*', '', _aline)
                    _aline = _deobfuscate_email(_aline)
                    _ae = _extract_email(_aline)
                    if _ae:
                        _0c5_known_emails.add(_ae)
                    _an = _strip_email(_aline).strip()
                    if _an and len(_an) > 2:
                        # Normalise to lowercase for matching
                        _0c5_known_names.add(_an.lower())
            if _0c5_known_emails or _0c5_known_names:
                _0c5_paras = working_text.split("\n\n")
                _0c5_count = 0
                for _pi, _para in enumerate(_0c5_paras):
                    if not re.match(r'^\s*Files:', _para):
                        continue
                    # Only touch the catch-all stanza
                    _s_files = []
                    for _line in _para.split("\n"):
                        if _line.startswith("Files:"):
                            _s_files.extend(_line[6:].strip().split())
                        elif _s_files and _line.startswith("       "):
                            _s_files.extend(_line.strip().split())
                        elif _s_files:
                            break
                    if _s_files != ["*"]:
                        continue
                    # Parse copyright lines
                    lines = _para.split("\n")
                    cop_start = cop_end = None
                    cop_values = []
                    in_copyright = False
                    for _li, _line in enumerate(lines):
                        if _line.startswith("Copyright:"):
                            in_copyright = True
                            if cop_start is None:
                                cop_start = _li
                            val = _line[10:].strip()
                            if val:
                                cop_values.append(val)
                            cop_end = _li + 1
                        elif in_copyright and re.match(
                                r'^\s{2,}', _line):
                            val = _line.strip()
                            if val:
                                cop_values.append(val)
                            cop_end = _li + 1
                        else:
                            in_copyright = False
                    if cop_start is None or not cop_values:
                        continue
                    # Filter: keep holders whose email or name
                    # matches an AUTHORS entry
                    kept = []
                    for _cv in cop_values:
                        _ce = _extract_email(_cv)
                        if _ce and _ce in _0c5_known_emails:
                            kept.append(_cv)
                            continue
                        _cn = _strip_email(_cv).strip()
                        # Strip leading year(s)
                        _cn = re.sub(
                            r'^[\d, -]+', '', _cn).strip()
                        if _cn and _cn.lower() in _0c5_known_names:
                            kept.append(_cv)
                            continue
                        # Fuzzy: check if any known name is a
                        # substring or vice versa (handles
                        # "digiKam developers team" etc.)
                        _cn_low = _cn.lower()
                        if any(kn in _cn_low or _cn_low in kn
                               for kn in _0c5_known_names
                               if len(kn) > 4):
                            kept.append(_cv)
                            continue
                    dropped = len(cop_values) - len(kept)
                    if dropped == 0 or not kept:
                        continue  # nothing to drop, or would empty
                    # Rebuild copyright block
                    cop_lines = [f"Copyright: {kept[0]}"]
                    for _kv in kept[1:]:
                        cop_lines.append(f"           {_kv}")
                    new_lines = (lines[:cop_start] + cop_lines
                                 + lines[cop_end:])
                    _0c5_paras[_pi] = "\n".join(new_lines)
                    _0c5_count = dropped
                if _0c5_count:
                    working_text = "\n\n".join(_0c5_paras)
                    changes_made += 1
                    print(f"  Trimmed {_0c5_count} non-project "
                          f"copyright holder(s) from Files: * "
                          f"(kept {len(kept)} from {_authors_path}).\n")

        # ── Fix 0c2: Add missing Copyright to Files stanzas ──────────
        # Decopy sometimes emits Files stanzas without a Copyright:
        # field, or with a placeholder "Copyright: FIXME".
        # For Files: *, infer holders from scanner data for
        # root-level files; for others, use the scanner data for the
        # stanza's file paths.  Falls back to packager identity.
        _0c2_paras = working_text.split("\n\n")
        _0c2_count = 0
        for _pi, _para in enumerate(_0c2_paras):
            if not re.match(r'^\s*Files:', _para):
                continue
            _has_cop = re.search(r'^Copyright:', _para, re.MULTILINE)
            _is_fixme_cop = bool(
                _has_cop and re.search(
                    r'^Copyright:\s*FIXME\s*$', _para, re.MULTILINE))
            if _has_cop and not _is_fixme_cop:
                continue  # already has real Copyright
            # Extract file patterns from this stanza
            _stanza_lines = _para.split("\n")
            _stanza_files = []
            _files_end = 0
            for _li, _line in enumerate(_stanza_lines):
                if _line.startswith("Files:"):
                    rest = _line[6:].strip()
                    if rest:
                        _stanza_files.extend(rest.split())
                    _files_end = _li + 1
                elif _files_end and _line.startswith("       "):
                    _stanza_files.extend(_line.strip().split())
                    _files_end = _li + 1
                else:
                    break

            is_catchall = _stanza_files == ["*"]

            # Gather copyright holders from scanner data
            _inferred_cops = []
            _fsf_re_0c2 = re.compile(
                r'free\s+software\s+foundation', re.IGNORECASE)
            for r in scan_results:
                fp = r["path"].lstrip("./")
                # Check if this file is governed by the stanza
                governed = False
                if is_catchall:
                    # For *, use root-level files (no subdirectory)
                    governed = "/" not in fp
                else:
                    for pat in _stanza_files:
                        if fnmatch.fnmatch(fp, pat):
                            governed = True
                            break
                if not governed:
                    continue
                # Check if stanza's license is GPL-family
                _lic_m = re.search(r'^License:\s*(.+)', _para,
                                   re.MULTILINE)
                _is_gpl_0c2 = bool(
                    _lic_m and re.search(
                        r'\b(GPL|LGPL|AGPL|GFDL)\b',
                        _lic_m.group(1), re.IGNORECASE))
                for c in r.get("copyrights", []):
                    if c and not (_is_gpl_0c2
                                  and _fsf_re_0c2.search(c)):
                        _inferred_cops.append(c)

            merged = merge_copyrights(_inferred_cops)
            if not merged and is_catchall:
                # For Files: *, try to use the upstream author inferred
                # by Fix 0b (GitHub API, package metadata, etc.).
                if _gh_author_name:
                    _yr = _gh_created_year or ""
                    if _yr:
                        merged = [f"{_yr} {_gh_author_name}"]
                    else:
                        merged = [_gh_author_name]
                    print(f"  Inferred Files: * copyright from "
                          f"upstream: {merged[0]}")
                elif _upstream_contact and \
                        _upstream_contact != _packager_contact:
                    # Use upstream contact (from metadata), not packager
                    merged = [_upstream_contact]
                    print(f"  Inferred Files: * copyright from "
                          f"upstream metadata: {merged[0]}")
            if not merged:
                # Fallback: packager identity — only for debian/*
                # or catch-all stanzas; upstream files get FIXME.
                _is_debian_stanza = any(
                    p.startswith("debian/") for p in _stanza_files)
                if is_catchall or _is_debian_stanza:
                    _fb_parts = []
                    if packager_year:
                        _fb_parts.append(packager_year)
                    if packager_name:
                        _fb_parts.append(packager_name)
                    if packager_email:
                        _fb_parts.append(f"<{packager_email}>")
                    if _fb_parts:
                        merged = [" ".join(_fb_parts)]
                else:
                    merged = ["FIXME"]
            if not merged:
                continue

            cop_field = format_copyright_field(merged)
            if _is_fixme_cop:
                # Replace existing Copyright: FIXME line
                new_lines = []
                for _line in _stanza_lines:
                    if re.match(r'^Copyright:\s*FIXME\s*$', _line):
                        new_lines.append(f"Copyright: {cop_field}")
                    else:
                        new_lines.append(_line)
            else:
                # Insert Copyright: after the Files: block
                new_lines = (_stanza_lines[:_files_end]
                             + [f"Copyright: {cop_field}"]
                             + _stanza_lines[_files_end:])
            _0c2_paras[_pi] = "\n".join(new_lines)
            _0c2_count += 1

        if _0c2_count:
            working_text = "\n\n".join(_0c2_paras)
            changes_made += 1
            print(f"  Added missing Copyright field to "
                  f"{_0c2_count} stanza(s).\n")

        # ── Fix 0d: Remove license text files from Files stanzas ─────
        # Whole license text files (e.g. LICENSES/*.txt, COPYING, etc.)
        # are covered by the Files: * catch-all and should not appear in
        # per-file stanzas.  Decopy sometimes groups them with source
        # files that share the same license.
        _0d_paras = working_text.split("\n\n")
        _0d_count = 0
        for _pi, _para in enumerate(_0d_paras):
            if not re.match(r'^\s*Files:', _para):
                continue
            lines = _para.split("\n")
            # Collect Files: paths
            file_paths = []
            files_end = 0
            for li, line in enumerate(lines):
                if line.startswith("Files:"):
                    rest = line[6:].strip()
                    if rest:
                        for p in rest.split():
                            file_paths.append(p)
                    files_end = li + 1
                elif files_end and line.startswith("       "):
                    for p in line.strip().split():
                        file_paths.append(p)
                    files_end = li + 1
                else:
                    break
            if not file_paths:
                continue
            # Filter out license text files
            kept = [p for p in file_paths if not is_metadata_file(p)]
            if len(kept) == len(file_paths):
                continue
            if not kept:
                # All paths were license files — remove entire stanza
                _0d_paras[_pi] = ""
                _0d_count += 1
                continue
            # Rebuild Files: field
            new_files = [f"Files: {kept[0]}"]
            for kp in kept[1:]:
                new_files.append(f"       {kp}")
            new_lines = new_files + lines[files_end:]
            _0d_paras[_pi] = "\n".join(new_lines)
            _0d_count += 1
        if _0d_count:
            working_text = "\n\n".join(p for p in _0d_paras if p.strip())
            changes_made += 1
            print(f"  Removed license text file(s) from "
                  f"{_0d_count} stanza(s) (covered by Files: *).\n")

        # ── Fix 1: Add stanzas for mismatched files ──────────────────
        if real_mismatches:
            # Group mismatched files by detected license, skipping
            # debian/*, symlinks, metadata/infrastructure files
            # (the latter are covered by the Files: * catch-all),
            # and files already covered by a specific (non-catch-all)
            # stanza — those already have an intentional declaration.
            def _has_specific_stanza(filepath):
                """True if filepath matches any existing stanza glob other
                than the '*' catch-all.  Such files should not get a
                duplicate stanza; the existing declaration is intentional."""
                fp = filepath.lstrip("./")
                for glob_pat, _ in stanzas:
                    if glob_pat == "*":
                        continue
                    if fnmatch.fnmatch(fp, glob_pat) or fp == glob_pat:
                        return True
                return False

            by_license = defaultdict(list)
            for (det, decl), files in real_mismatches.items():
                filtered = [f for f in files
                            if not f.lstrip("./").startswith("debian/")
                            and not os.path.islink(f.lstrip("./"))
                            and not is_metadata_file(f)
                            and not _has_specific_stanza(f)]
                if filtered:
                    by_license[det].extend(filtered)

            # ── Group by (license, per-file copyright holders) ────────
            # Instead of broad dir/* globs that lump unrelated holders,
            # produce per-author stanzas.  Files with the same author(s)
            # and license share a single stanza.

            def _holder_key(cop):
                """Normalize a copyright string to an author identity key."""
                s = cop.strip()
                s = re.sub(r'^(?:Copyright\s+)?(?:\(c\)\s+)?', '', s, flags=re.I)
                s = re.sub(r'^[\d,\s-]+', '', s).strip()
                s = re.sub(r'\s*<[^>]*>', '', s)   # strip email
                s = re.sub(r'[\s\-]+', '', s)      # collapse spaces/hyphens
                return s.lower()

            author_groups = defaultdict(lambda: {"files": [], "cops": []})
            for det_lic, files in by_license.items():
                for f in files:
                    fps = {f, f.lstrip("./")}
                    file_cops = []
                    for r in scan_results:
                        if r["path"] in fps or r["path"].lstrip("./") in fps:
                            file_cops = [c for c in r.get("copyrights", []) if c]
                            # Filter FSF boilerplate from GPL-family files
                            _fl = " ".join(r.get("licenses", [])).lower()
                            if re.search(r'\b(gpl|lgpl|agpl|gfdl|gnu)\b', _fl):
                                file_cops = [
                                    c for c in file_cops
                                    if not re.search(r'free\s+software\s+foundation',
                                                     c, re.IGNORECASE)]
                            break
                    ak = frozenset(_holder_key(c) for c in file_cops)
                    key = (det_lic, ak)
                    author_groups[key]["files"].append(f)
                    author_groups[key]["cops"].extend(file_cops)

            if author_groups:
                print("The following new Files: stanzas are proposed for")
                print("files whose detected license differs from what's declared:\n")

            for (det_lic, _ak), grp in sorted(
                    author_groups.items(),
                    key=lambda kv: (kv[0][0], sorted(f.lstrip("./") for f in kv[1]["files"])[0])):
                files = grp["files"]
                cops_raw = sorted(set(grp["cops"]))
                merged_cops = merge_copyrights(cops_raw, file_paths=files)
                cop_field = format_copyright_field(merged_cops)
                # Apply glob simplification to file list
                # Build directory file counts from all stanzas so
                # dir/* is only used when this stanza owns all files
                # in the directory.
                _fix1_dir_counts = defaultdict(int)
                for _sg, _sl in stanzas:
                    if _sg == "*" or "*" in _sg:
                        continue
                    _sd = str(Path(_sg).parent)
                    _fix1_dir_counts[_sd] += 1
                _fix1_all_files = {r["path"].lstrip("./")
                                   for r in scan_results}
                globs = _simplify_file_list(
                    sorted(f.lstrip("./") for f in files),
                    all_dir_counts=_fix1_dir_counts,
                    all_files=_fix1_all_files)

                print(f"  Proposed stanza ({len(files)} file(s)):")
                print(f"  ┌──────────────────────────────────────")
                if len(globs) <= 3:
                    print(f"  │ Files: {globs[0]}")
                    for g in globs[1:]:
                        print(f"  │        {g}")
                else:
                    print(f"  │ Files: {globs[0]}")
                    print(f"  │        {globs[1]}")
                    print(f"  │        ... ({len(globs)} globs total)")
                for i, cl in enumerate(merged_cops or ["FIXME"]):
                    tag = "Copyright" if i == 0 else "          "
                    print(f"  │ {tag}: {cl}")
                print(f"  │ License: {det_lic}")
                print(f"  └──────────────────────────────────────")

                answer = ask("  Add this stanza? [y/N/e(dit)/A/X] ", batch=True).strip().lower()

                if answer in ("y", "yes"):
                    # Build the stanza text
                    files_str = format_files_field(globs)
                    stanza = f"\n{files_str}\nCopyright: {cop_field}\nLicense: {det_lic}\n"
                    pos = find_stanza_insert_pos(working_text)
                    working_text = working_text[:pos] + stanza + working_text[pos:]
                    changes_made += 1
                    print(f"  ✓ Added.\n")
                elif answer in ("e", "edit"):
                    print("  Edit copyright holders (one per line, blank to finish):")
                    edited_cops = []
                    for cl in merged_cops:
                        new_cl = ask(f"    [{cl}]: ").strip()
                        edited_cops.append(new_cl if new_cl else cl)
                    extra = ask("    [add more?]: ").strip()
                    while extra:
                        edited_cops.append(extra)
                        extra = ask("    [add more?]: ").strip()
                    cop_field = format_copyright_field(edited_cops if edited_cops else merged_cops)
                    print(f"  Enter license (or press Enter for '{det_lic}'):")
                    new_lic = ask("    License: ").strip()
                    if not new_lic:
                        new_lic = det_lic
                    files_str = format_files_field(globs)
                    stanza = f"\n{files_str}\nCopyright: {cop_field}\nLicense: {new_lic}\n"
                    pos = find_stanza_insert_pos(working_text)
                    working_text = working_text[:pos] + stanza + working_text[pos:]
                    changes_made += 1
                    print(f"  ✓ Added (edited).\n")
                else:
                    print(f"  ✗ Skipped.\n")

        # ── Fix 1c: Correct wrong license in existing stanzas ───────
        # Fix 1 skips files that already have a specific stanza, but
        # that stanza may declare the wrong license (e.g. decopy said
        # MIT-open-group but scancode + licensecheck both say
        # HPND-sell-variant).  When cross-validation confirms the
        # scanner's result, offer to update the stanza license.
        if real_mismatches and all_sanity_results:
            # Collect mismatched files that DO have a specific stanza
            _stanza_mismatches = defaultdict(lambda: {"det": None, "decl": None, "files": []})
            for (det, decl), files in real_mismatches.items():
                for f in files:
                    fp = f.lstrip("./")
                    if fp.startswith("debian/"):
                        continue
                    # Find which specific stanza covers this file
                    for glob_pat, lic in stanzas:
                        if glob_pat == "*":
                            continue
                        if fnmatch.fnmatch(fp, glob_pat) or fp == glob_pat:
                            key = (glob_pat, det, decl)
                            _stanza_mismatches[key]["det"] = det
                            _stanza_mismatches[key]["decl"] = decl
                            _stanza_mismatches[key]["files"].append(f)
                            break
            fix1c_count = 0
            for (glob_pat, det, decl), info in sorted(_stanza_mismatches.items()):
                # Check if cross-validation supports the primary scanner
                _confirmed = False
                _confirming_tool = None
                for f in info["files"]:
                    fp_norm = "./" + f.lstrip("./")
                    fp_bare = f.lstrip("./")
                    for _tool, _sr in all_sanity_results.items():
                        raw = _sr.get(fp_bare, _sr.get(fp_norm, set()))
                        if raw:
                            secondary_dep5 = {to_dep5(l) for l in raw if l}
                            if det in secondary_dep5:
                                _confirmed = True
                                _confirming_tool = _tool
                                break
                            # Also check by family
                            det_fam = re.sub(r'[- ]\d[\d.]*\+?$', '', det).lower()
                            for sl in secondary_dep5:
                                sl_fam = re.sub(r'[- ]\d[\d.]*\+?$', '', sl).lower()
                                if det_fam == sl_fam:
                                    _confirmed = True
                                    _confirming_tool = _tool
                                    break
                        if _confirmed:
                            break
                    if _confirmed:
                        break

                if not _confirmed:
                    continue

                n = len(info["files"])
                print(f"  Stanza '{glob_pat}' declares '{decl}' but "
                      f"{scanner} + {_confirming_tool} both detect "
                      f"'{det}' ({n} file(s))")
                answer = ask(f"  Update license {decl} → {det}? "
                             f"[y/N/A/X] ", batch=True).strip().lower()
                if answer in ("y", "yes"):
                    # Find the stanza paragraph in working_text and
                    # replace the License: line
                    _updated = False
                    new_paras_1c = []
                    for para in working_text.split("\n\n"):
                        plines = para.strip().split("\n")
                        has_files = any(
                            l.strip().startswith("Files:")
                            for l in plines)
                        if not has_files:
                            new_paras_1c.append(para)
                            continue
                        # Check if this stanza's Files: contain our glob
                        _file_globs = []
                        _in_files_1c = False
                        for l in plines:
                            if l.startswith("Files:"):
                                _file_globs.extend(l[6:].split())
                                _in_files_1c = True
                            elif _in_files_1c and l.startswith(" ") and not l.startswith(" ."):
                                _file_globs.extend(l.strip().split())
                            else:
                                _in_files_1c = False
                        if glob_pat not in _file_globs:
                            new_paras_1c.append(para)
                            continue
                        # Check the License: field matches decl
                        _lic_match = False
                        for l in plines:
                            if l.startswith("License:"):
                                _lic_val = l[8:].strip().split()[0]
                                if _lic_val == decl:
                                    _lic_match = True
                                break
                        if not _lic_match or _updated:
                            new_paras_1c.append(para)
                            continue
                        # Replace the license
                        new_lines = []
                        for l in plines:
                            if l.startswith("License:"):
                                new_lines.append(f"License: {det}")
                            else:
                                new_lines.append(l)
                        new_paras_1c.append("\n".join(new_lines))
                        _updated = True
                    if _updated:
                        working_text = "\n\n".join(new_paras_1c)
                        fix1c_count += 1
                        print(f"  ✓ Updated.\n")
                    else:
                        print(f"  ✗ Stanza not found in text.\n")
                else:
                    print(f"  ✗ Skipped.\n")

            if fix1c_count:
                changes_made += 1
                print(f"Corrected license in {fix1c_count} stanza(s) "
                      f"based on cross-validation consensus.\n")

        # ── Fix 1b: Resolve 'Unknown' license stanzas ───────────────
        # Decopy sometimes assigns 'Unknown' when it cannot determine
        # the license.  Use scanner results to resolve where possible.
        unknown_paras = []
        new_paras_1b = []
        para_idx = -1
        for para in working_text.split("\n\n"):
            para_idx += 1
            plines = para.strip().split("\n")
            has_files = any(l.strip().startswith("Files:") for l in plines)
            lic_line = None
            for l in plines:
                if l.startswith("License:"):
                    lic_line = l
                    break
            if (has_files and lic_line
                    and lic_line[8:].strip().split()[0].lower() == "unknown"):
                # Collect the file globs from this stanza
                globs = []
                _in_files_1b = False
                for l in plines:
                    if l.startswith("Files:"):
                        globs.extend(l[6:].split())
                        _in_files_1b = True
                    elif _in_files_1b and l.startswith(" ") and not l.startswith(" ."):
                        globs.extend(l.strip().split())
                    else:
                        _in_files_1b = False
                unknown_paras.append((para_idx, para, globs))
            new_paras_1b.append(para)

        if unknown_paras:
            fix1b_count = 0
            # Build a map from file path → detected licenses from scanner
            _file_det = {}
            for r in scan_results:
                fp = r["path"].lstrip("./")
                lics = [l for l in r.get("licenses", [])
                        if l.upper() not in ("UNKNOWN",)]
                if lics:
                    _file_det[fp] = lics

            for pidx, para, globs in unknown_paras:
                # Find files matching these globs
                matched_lics = defaultdict(int)
                for fp in _file_det:
                    for g in globs:
                        if fnmatch.fnmatch(fp, g) or g == "*":
                            for l in _file_det[fp]:
                                dep5 = to_dep5(l)
                                matched_lics[dep5] += 1
                            break
                if matched_lics:
                    # Pick the dominant license
                    best_lic = max(matched_lics, key=matched_lics.get)
                    print(f"  Unknown license stanza ({', '.join(globs[:3])}"
                          f"{'...' if len(globs) > 3 else ''}) → "
                          f"scanner detected: {best_lic}")
                    answer = ask(f"  Replace Unknown with {best_lic}? "
                                 f"[y/N/A/X] ", batch=True).strip().lower()
                    if answer in ("y", "yes"):
                        new_para = re.sub(
                            r'^(License:)\s+Unknown',
                            rf'\1 {best_lic}',
                            new_paras_1b[pidx],
                            flags=re.MULTILINE)
                        new_paras_1b[pidx] = new_para
                        fix1b_count += 1
                        print(f"  ✓ Replaced.\n")
                    else:
                        print(f"  ✗ Skipped.\n")
                else:
                    # No scanner data.  If a catch-all * stanza exists,
                    # these non-source files are already covered by it —
                    # remove the redundant Unknown stanza so they
                    # inherit the project's main license.
                    has_catchall = any(
                        g == "*" for g, _ in stanzas)
                    if has_catchall:
                        print(f"  Unknown license stanza ({', '.join(globs[:3])}"
                              f"{'...' if len(globs) > 3 else ''}) → "
                              f"no scanner data; covered by Files: *")
                        answer = ask(
                            f"  Remove stanza (files inherit from *)? "
                            f"[y/N/A/X] ", batch=True).strip().lower()
                        if answer in ("y", "yes"):
                            new_paras_1b[pidx] = ""  # mark for removal
                            fix1b_count += 1
                            print(f"  ✓ Removed.\n")
                        else:
                            print(f"  ✗ Skipped.\n")
                    else:
                        print(f"  Unknown license stanza "
                              f"({', '.join(globs[:3])}"
                              f"{'...' if len(globs) > 3 else ''}) → "
                              f"no scanner data; leaving as Unknown.")

            if fix1b_count:
                working_text = "\n\n".join(
                    p for p in new_paras_1b if p.strip())
                changes_made += 1
                # Also remove any standalone License: Unknown block
                # that is no longer needed
                _unknown_used = False
                for _p in working_text.split("\n\n"):
                    _pl = _p.strip().split("\n")
                    _hf = any(l.strip().startswith("Files:") for l in _pl)
                    if _hf:
                        for _l in _pl:
                            if (_l.startswith("License:")
                                    and _l[8:].strip().split()[0].lower()
                                    == "unknown"):
                                _unknown_used = True
                                break
                if not _unknown_used:
                    # Remove standalone License: Unknown block
                    working_text = re.sub(
                        r'\n\nLicense: Unknown\n(?:[^\n]*\n)*?(?=\n|$)',
                        '', working_text)

        # ── Fix 2: Remove superfluous files/patterns/stanzas ────────
        superfluous = check_superfluous(working_text)
        if superfluous:
            entirely = [s for s in superfluous if s["remove_entirely"]]
            partially = [s for s in superfluous
                         if not s["remove_entirely"]]
            total_count = len(entirely) + len(partially)
            print(f"{total_count} superfluous stanza(s)/pattern(s) found:\n")

            for sup in entirely:
                ps = sup["stanza"]
                files_preview = " ".join(ps["files"][:3])
                if len(ps["files"]) > 3:
                    files_preview += f" ... (+{len(ps['files'])-3})"
                print(f"  Stanza: Files: {files_preview}")
                print(f"    → remove entirely ({sup['reason']})")

            for sup in partially:
                ps = sup["stanza"]
                files_preview = " ".join(ps["files"][:3])
                if len(ps["files"]) > 3:
                    files_preview += f" ... (+{len(ps['files'])-3})"
                print(f"  Stanza: Files: {files_preview}")
                print(f"    → remove stale: "
                      f"{', '.join(sup['stale_patterns'])}")

            answer = ask(
                "\n  Remove superfluous patterns/stanzas? "
                "[y/N/A/X] ", batch=True).strip().lower()
            if answer in ("y", "yes"):
                paras = working_text.split("\n\n")
                new_paras = []
                removed_lics = set()

                for para in paras:
                    matched_sup = None
                    for sup in superfluous:
                        if sup["stanza"]["para_text"] == para:
                            matched_sup = sup
                            break
                    if matched_sup is None:
                        new_paras.append(para)
                        continue

                    if matched_sup["remove_entirely"]:
                        removed_lics.add(
                            matched_sup["stanza"]["license"])
                        fp = " ".join(
                            matched_sup["stanza"]["files"][:2])
                        if len(matched_sup["stanza"]["files"]) > 2:
                            fp += "..."
                        print(f"  ✓ Removed stanza: "
                              f"Files: {fp}")
                    else:
                        # Remove stale patterns, keep the rest
                        kept = matched_sup["kept_patterns"]
                        lines = para.split("\n")
                        new_lines = []
                        in_files = False
                        files_replaced = False
                        for line in lines:
                            if (line.startswith("Files:")
                                    and not files_replaced):
                                in_files = True
                                new_lines.append(
                                    format_files_field(kept))
                                files_replaced = True
                            elif in_files and line.startswith(
                                    " ") and not line.startswith(
                                    " ."):
                                # Skip old continuation lines
                                continue
                            else:
                                in_files = False
                                new_lines.append(line)
                        new_paras.append("\n".join(new_lines))
                        for sp in matched_sup["stale_patterns"]:
                            print(f"  ✓ Removed pattern: {sp}")

                working_text = "\n\n".join(
                    p for p in new_paras if p.strip())

                # Remove orphaned standalone License: blocks
                remaining_lics = set()
                for para in working_text.split("\n\n"):
                    plines = para.strip().split("\n")
                    has_files = any(
                        l.strip().startswith("Files:")
                        for l in plines)
                    if has_files:
                        for l in plines:
                            if l.startswith("License:"):
                                lic = (l[8:].strip().split()[0]
                                       if l[8:].strip() else None)
                                if lic:
                                    remaining_lics.add(lic)

                orphaned_paras = []
                for para in working_text.split("\n\n"):
                    plines = para.strip().split("\n")
                    if not plines:
                        continue
                    first = plines[0]
                    has_files = any(
                        l.strip().startswith("Files:")
                        for l in plines)
                    if (first.startswith("License:")
                            and not has_files):
                        lic_name = (
                            first[8:].strip().split()[0]
                            if first[8:].strip() else None)
                        if (lic_name
                                and lic_name in removed_lics
                                and lic_name not in remaining_lics):
                            print(f"  ✓ Removed orphaned "
                                  f"License block: {lic_name}")
                            continue
                    orphaned_paras.append(para)

                working_text = "\n\n".join(orphaned_paras)
                changes_made += 1
                print()
            else:
                print("  ✗ Skipped.\n")

        # ── Fix 2b: Drop zero-evidence files from specific stanzas ──
        # Files with no scanner-detected license or copyright have no
        # evidence supporting their placement in a non-catchall stanza.
        # They should fall through to the Files: * catch-all instead.
        # (Common with TODO, README, data files that decopy assigned
        # heuristically.)
        _2b_no_evidence = set()
        for _r in scan_results:
            _fp = _r["path"].lstrip("./")
            if _fp.startswith("debian/"):
                continue
            if not _r.get("licenses") and not _r.get("copyrights"):
                _2b_no_evidence.add(_fp)
        if _2b_no_evidence:
            _2b_para_stanzas = _parse_paragraph_stanzas(working_text)
            _2b_dropped = 0
            for _ps in _2b_para_stanzas:
                if _ps["files"] == ["*"]:
                    continue
                removable = [f for f in _ps["files"]
                             if "*" not in f and f in _2b_no_evidence]
                if not removable:
                    continue
                kept = [f for f in _ps["files"] if f not in removable]
                if not kept:
                    # All files are zero-evidence — remove the entire
                    # stanza so they fall through to Files: * catch-all.
                    _orig = _ps["para_text"]
                    pos = working_text.find(_orig)
                    if pos >= 0:
                        # Remove the paragraph and surrounding blank line
                        end = pos + len(_orig)
                        if working_text[end:end+2] == "\n\n":
                            end += 2
                        elif working_text[end:end+1] == "\n":
                            end += 1
                        working_text = (working_text[:pos]
                                        + working_text[end:])
                        _2b_dropped += len(removable)
                    continue
                # Rebuild the Files: field
                files_str = format_files_field(kept)
                _orig = _ps["para_text"]
                _olines = _orig.split("\n")
                _fs = None
                _fe = None
                for _li, _line in enumerate(_olines):
                    if _line.startswith("Files:"):
                        _fs = _li
                    elif (_fs is not None and _fe is None
                          and not _line.startswith("       ")):
                        _fe = _li
                        break
                if _fs is None:
                    continue
                if _fe is None:
                    _fe = len(_olines)
                new_para = "\n".join(
                    _olines[:_fs] + [files_str] + _olines[_fe:])
                pos = working_text.find(_orig)
                if pos >= 0:
                    working_text = (working_text[:pos] + new_para
                                    + working_text[pos + len(_orig):])
                    _2b_dropped += len(removable)
            if _2b_dropped:
                changes_made += 1
                print(f"Dropped {_2b_dropped} zero-evidence file(s) "
                      f"from specific stanzas (covered by Files: *).\n")

        # ── Fix 3: Add missing license text blocks ───────────────────
        # Check if any new licenses were introduced that lack a text block
        new_lics_in_text = set()
        for m in re.finditer(r'^License: (\S+)', working_text, re.MULTILINE):
            new_lics_in_text.add(m.group(1))

        # Licenses that appear in Files stanzas but have no standalone block
        existing_blocks = set()
        for para in working_text.split("\n\n"):
            lines = para.strip().split("\n")
            if not lines:
                continue
            first = lines[0]
            has_files = any(l.strip().startswith("Files:") for l in lines)
            if first.startswith("License:") and not has_files:
                lic_name = first[8:].strip().split()[0] if first[8:].strip() else None
                if lic_name:
                    existing_blocks.add(lic_name)
                    norm = to_dep5(lic_name)
                    if norm != lic_name:
                        existing_blocks.add(norm)

        # Collect licenses used in file stanzas
        stanza_lics = set()
        for para in working_text.split("\n\n"):
            lines = para.strip().split("\n")
            has_files = any(l.strip().startswith("Files:") for l in lines)
            if has_files:
                for l in lines:
                    if l.startswith("License:"):
                        lic = l[8:].strip().split()[0] if l[8:].strip() else None
                        if lic:
                            stanza_lics.add(lic)

        missing_blocks = stanza_lics - existing_blocks

        COMMON_LICENSES = {
            "GPL-1", "GPL-1+", "GPL-2", "GPL-2+", "GPL-3", "GPL-3+",
            "LGPL-2", "LGPL-2+", "LGPL-2.1", "LGPL-2.1+", "LGPL-3", "LGPL-3+",
            "AGPL-3", "AGPL-3+",
            "Apache-2.0", "MPL-1.1", "MPL-2.0",
            "GFDL", "GFDL-1.1", "GFDL-1.1+", "GFDL-1.2", "GFDL-1.2+", "GFDL-1.3", "GFDL-1.3+",
            "Artistic", "BSD", "CC0-1.0",
        }

        if missing_blocks:
            print(f"{len(missing_blocks)} license(s) need a License text block:\n")
            for lic in sorted(missing_blocks):
                slug = lic.rstrip("+")
                if slug in SYSTEM_COMMON_LICENSES or lic in COMMON_LICENSES:
                    body = _dep5_common_license_body(lic)
                    block = f"\nLicense: {lic}\n{body}\n"
                    print(f"  {lic} → will reference /usr/share/common-licenses/{slug}")
                else:
                    # Try fetching full text
                    fetched = fetch_license_text(lic)
                    if fetched:
                        body = format_license_body(fetched)
                        block = f"\nLicense: {lic}\n{body}\n"
                        print(f"  {lic} → fetched full text from SPDX/CC")
                    else:
                        block = (f"\nLicense: {lic}\n"
                                 f" FIXME: Add the full license text here.\n")
                        print(f"  {lic} → FIXME stub (you must add the full text)")

                answer = ask(f"  Add License block for {lic}? [y/N/A/X] ", batch=True).strip().lower()
                if answer in ("y", "yes"):
                    working_text = working_text.rstrip("\n") + "\n" + block
                    changes_made += 1
                    print(f"  ✓ Added.\n")
                else:
                    print(f"  ✗ Skipped.\n")

        # ── Fix 3b: Fix empty/stub standalone license blocks ─────────
        # Decopy (and manual edits) may leave standalone License blocks
        # with placeholder text like "Add the corresponding license text
        # here" or a Comment: stub.  Replace them with proper content.
        fix3b_count = 0
        new_paras = []
        for para in working_text.split("\n\n"):
            plines = para.strip().split("\n")
            if not plines:
                new_paras.append(para)
                continue
            first = plines[0]
            has_files = any(l.strip().startswith("Files:") for l in plines)
            if not (first.startswith("License:") and not has_files):
                new_paras.append(para)
                continue
            lic_name = first[8:].strip().split()[0] if first[8:].strip() else None
            if not lic_name:
                new_paras.append(para)
                continue
            # Gather body text (continuation lines + Comment: lines)
            body = "\n".join(plines[1:]).strip()
            body_text = re.sub(r'(?m)^Comment:.*', '', body).strip()
            body_text = re.sub(r'(?m)^ ', '', body_text).strip()
            # Skip if already has a valid common-licenses reference
            # (but NOT if it references a file that doesn't exist)
            _cl_ref = re.search(r'/usr/share/common-licenses/([\w.+-]+)', body)
            if _cl_ref:
                _cl_name = _cl_ref.group(1)
                _cl_slug = lic_name.rstrip("+")
                if (_cl_name in SYSTEM_COMMON_LICENSES
                        or _cl_slug in SYSTEM_COMMON_LICENSES
                        or lic_name in COMMON_LICENSES):
                    new_paras.append(para)
                    continue
                # References a non-existent common-licenses file — force fix
                is_stub = True
                print(f"  License '{lic_name}' references "
                      f"/usr/share/common-licenses/{_cl_name} which does "
                      f"not exist — replacing", file=sys.stderr)
            else:
                # Only fix if body is empty or a stub placeholder
                is_stub = (len(body_text) < 80
                           or re.search(r'FIXME|TODO|add the .* license text|'
                                        r'add the .* text here|corresponding license',
                                        body, re.IGNORECASE))
            if not is_stub:
                new_paras.append(para)
                continue

            slug = lic_name.rstrip("+")
            if slug in SYSTEM_COMMON_LICENSES or lic_name in COMMON_LICENSES:
                body = _dep5_common_license_body(lic_name)
                replacement = f"License: {lic_name}\n{body}"
                print(f"  Fixing empty '{lic_name}' block → "
                      f"/usr/share/common-licenses/{slug}")
                new_paras.append(replacement)
                fix3b_count += 1
            elif not no_fetch:
                norm_name = to_dep5(lic_name)
                fetched = fetch_license_text(norm_name)
                if fetched:
                    body_fmt = format_license_body(fetched)
                    replacement = f"License: {norm_name}\n{body_fmt}"
                    print(f"  Fixing empty '{norm_name}' block → "
                          f"fetched from SPDX/CC")
                    new_paras.append(replacement)
                    fix3b_count += 1
                else:
                    new_paras.append(para)
            else:
                new_paras.append(para)

        if fix3b_count:
            working_text = "\n\n".join(new_paras)
            changes_made += 1
            print(f"Replaced {fix3b_count} empty/stub license block(s) "
                  f"with proper content.\n")

        # ── Fix 4: Update copyright holders ──────────────────────────
        # Re-parse stanzas from working_text so holder checking reflects
        # any stanzas added by Fix 1 (otherwise files still governed by
        # the catch-all * get holders dumped into the wrong stanza).
        stanzas_now = []
        stanza_copyrights_now = {}
        for para in working_text.split("\n\n"):
            _files = []
            _lic = None
            _cops = []
            _cf = None
            for line in para.split("\n"):
                if line.startswith("Files:"):
                    rest = line[6:].strip()
                    if rest:
                        _files.extend(rest.split())
                    _cf = "files"
                elif line.startswith("Copyright:"):
                    cop_val = line[10:].strip()
                    if cop_val:
                        _cops.append(cop_val)
                    _cf = "copyright"
                elif line.startswith("License:"):
                    _lic = line[8:].strip().split()[0] if line[8:].strip() else None
                    _cf = "license"
                elif _cf == "files" and line.startswith(" ") and not line.startswith(" ."):
                    _files.extend(line.strip().split())
                elif _cf == "copyright" and line.startswith(" ") and line.strip():
                    _cops.append(line.strip())
            if _files and _lic:
                # Filter out scanner false-positives from existing text
                _cops = [c for c in _cops if _is_plausible_copyright(c)]
                for f in _files:
                    stanzas_now.append((f, _lic))
                    stanza_copyrights_now[f] = _cops

        # Temporarily swap in the refreshed stanzas for holder checking
        _saved_stanzas = stanzas[:]
        _saved_cops = dict(stanza_copyrights)
        stanzas.clear()
        stanzas.extend(stanzas_now)
        stanza_copyrights.clear()
        stanza_copyrights.update(stanza_copyrights_now)

        try:
            missing_holders, extra_holders, star_file_map, holder_file_map = check_copyright_holders()
        finally:
            # Restore originals even on exception
            stanzas.clear()
            stanzas.extend(_saved_stanzas)
            stanza_copyrights.clear()
            stanza_copyrights.update(_saved_cops)

        # Build preferred-unicode-names dict from all declared copyrights
        # so that e.g. "Gräßlin" from one stanza is used everywhere.
        _unicode_pref = {}
        _upref_email = {}
        for _cops_list in stanza_copyrights_now.values():
            for _cop in _cops_list:
                _, _auth = _parse_copyright(_cop)
                if not _auth:
                    continue
                _nonly = _strip_email(_auth)
                _ukey = _normalize_unicode(_nonly).lower()
                _uemail = _extract_email(_auth)
                if _uemail and _uemail in _upref_email:
                    _ukey = _upref_email[_uemail]
                elif _uemail:
                    _upref_email[_uemail] = _ukey
                _has_uni = any(ord(c) > 127 for c in _auth)
                if _ukey not in _unicode_pref:
                    _unicode_pref[_ukey] = _auth
                elif _has_uni and not any(ord(c) > 127 for c in _unicode_pref[_ukey]):
                    _unicode_pref[_ukey] = _auth

        if missing_holders:
            print(f"{len(missing_holders)} copyright holder(s) detected but not declared:\n")
            # Group by glob pattern for cleaner prompts
            by_glob = defaultdict(list)
            for glob_pat, holder in missing_holders:
                by_glob[glob_pat].append(holder)

            for glob_pat, holders in sorted(by_glob.items()):
                # Clean holders: strip Copyright/(C) prefix, merge years
                cleaned = merge_copyrights(holders, prefer_unicode=_unicode_pref)

                if glob_pat == "*":
                    # Don't add deep-tree holders to the * catch-all.
                    # In DEP-5, Files: * is the project baseline/default.
                    # Holders from specific subdirectories belong in their
                    # own stanzas rather than being lumped into *.
                    star_files = set()
                    for h in holders:
                        star_files.update(star_file_map.get(h, set()))

                    # Exclude debian/* files (handled by Fix 5) and
                    # symlinks (not subject to separate copyright).
                    star_files = {
                        f for f in star_files
                        if not f.lstrip("./").startswith("debian/")
                        and not os.path.islink(f.lstrip("./"))
                    }
                    if not star_files:
                        continue

                    # Get the * stanza's license
                    star_license = "FIXME"
                    for g, l in stanzas_now:
                        if g == "*":
                            star_license = l
                            break

                    # Build no_glob_dirs from parent dirs of existing
                    # stanza globs so new dir/* globs don't override
                    # existing specific stanzas (DEP-5 last-match-wins).
                    _no_glob = set()
                    for g, l in stanzas_now:
                        if g != "*":
                            d = os.path.dirname(g)
                            if d:
                                _no_glob.add(d)

                    # Get the * stanza's declared holders (to exclude
                    # them — they're already covered by *).
                    star_declared = set()
                    for c in stanza_copyrights_now.get("*", []):
                        n = norm_copyright(c)
                        if n:
                            star_declared.add(n)

                    # Group files by their unique set of NEW holders
                    # (holders not already declared in *).
                    file_holders = {}  # filepath → frozenset of raw holders
                    for fp in star_files:
                        fp_cops = get_copyrights_for_files([fp])
                        # Keep only holders not already in *
                        new_cops = []
                        for c in fp_cops:
                            nc = norm_copyright(c)
                            if nc and not any(nc in d or d in nc
                                              for d in star_declared):
                                new_cops.append(c)
                        if new_cops:
                            key = frozenset(norm_copyright(c) for c in new_cops)
                            file_holders[fp] = (key, new_cops)

                    # Group files sharing the same holder set
                    from collections import OrderedDict
                    holder_groups = OrderedDict()  # key → (raw_holders, files)
                    for fp, (key, raw) in sorted(file_holders.items()):
                        if key not in holder_groups:
                            holder_groups[key] = (raw, [])
                        holder_groups[key][1].append(fp)

                    if not holder_groups:
                        continue

                    print(f"  Files governed by * have {len(cleaned)}"
                          f" undeclared holder(s).")
                    print(f"  Proposing directory-specific stanza(s)"
                          f" instead of")
                    print(f"  adding them to the catch-all:\n")

                    for _hkey, (raw_cops, group_files) in holder_groups.items():
                        globs = make_fix_globs(group_files,
                                               no_glob_dirs=_no_glob)
                        merged_cops = merge_copyrights(
                            raw_cops, prefer_unicode=_unicode_pref,
                            file_paths=group_files)
                        cop_field = format_copyright_field(merged_cops)

                        print(f"  ┌──────────────────────────────────────")
                        if len(globs) <= 3:
                            print(f"  │ Files: {globs[0]}")
                            for g in globs[1:]:
                                print(f"  │        {g}")
                        else:
                            print(f"  │ Files: {globs[0]}")
                            print(f"  │        {globs[1]}")
                            print(f"  │        "
                                  f"... ({len(globs)} globs total)")
                        for i, cl in enumerate(merged_cops or ["FIXME"]):
                            tag = "Copyright" if i == 0 else "          "
                            print(f"  │ {tag}: {cl}")
                        print(f"  │ License: {star_license}")
                        print(f"  └──────────────────────────────────────")

                        answer = ask(
                            "  Add this stanza? [y/N/A/X] ", batch=True).strip().lower()
                        if answer in ("y", "yes"):
                            files_str = format_files_field(globs)
                            stanza_text = (
                                f"\n{files_str}\n"
                                f"Copyright: {cop_field}\n"
                                f"License: {star_license}\n")
                            pos = find_stanza_insert_pos(working_text)
                            working_text = (working_text[:pos]
                                            + stanza_text
                                            + working_text[pos:])
                            changes_made += 1
                            print(f"  ✓ Added.\n")
                        else:
                            print(f"  ✗ Skipped.\n")
                    continue

                # Check per-file attribution: if the holder was only
                # detected in a minority of the stanza's files, don't
                # add it to the shared stanza (it would misattribute).
                # Find all files in the same stanza as glob_pat.
                _my_cops = stanza_copyrights_now.get(glob_pat)
                stanza_files_for_glob = set()
                if _my_cops is not None:
                    for g, l in stanzas_now:
                        if stanza_copyrights_now.get(g) is _my_cops:
                            stanza_files_for_glob.add(g)
                if len(stanza_files_for_glob) > 1:
                    # Multi-file stanza: check how many files actually
                    # had the holder(s) detected.  Each file has its own
                    # governing glob entry, so check every file in the
                    # stanza against the holder_file_map.
                    files_with_holder = set()
                    for g in stanza_files_for_glob:
                        for h in holders:
                            if holder_file_map.get((g, h)):
                                files_with_holder.add(g)
                                break
                    if files_with_holder and len(files_with_holder) < len(stanza_files_for_glob):
                        # Holder only applies to a subset — skip
                        print(f"  Stanza: Files: {glob_pat}")
                        for h in cleaned:
                            print(f"    ~ {h}  (detected in "
                                  f"{len(files_with_holder)}/"
                                  f"{len(stanza_files_for_glob)} "
                                  f"stanza files — skipped)")
                        print()
                        continue

                print(f"  Stanza: Files: {glob_pat}")
                for h in cleaned:
                    print(f"    + {h}")
                answer = ask(f"  Add missing holder(s) to this stanza? [y/N/A/X] ", batch=True).strip().lower()
                if answer in ("y", "yes"):
                    escaped_glob = re.escape(glob_pat)
                    # Find the stanza containing this glob anywhere in
                    # its Files: field (first line or continuation lines).
                    files_pat = r'(Files:[^\n]*(?:\n +[^\n]+)*)'
                    found = False
                    for fm in re.finditer(files_pat, working_text):
                        if re.search(r'(?:^Files:\s*|(?<=\n)\s+)' + escaped_glob + r'\s*$', fm.group(0), re.MULTILINE):
                            rest = working_text[fm.end():]
                            cop_m = re.match(r'(\nCopyright:[^\n]*(?:\n +[^\n]+)*)', rest)
                            if cop_m:
                                # Detect existing indent from copyright continuation lines
                                cop_lines = cop_m.group(0).split('\n')
                                existing_indent = " " * 11
                                for cl in cop_lines[2:]:
                                    m = re.match(r'^( +)', cl)
                                    if m:
                                        existing_indent = m.group(1)
                                        break
                                # Collect existing copyright lines from the stanza
                                existing_raw = []
                                for cl in cop_lines:
                                    cl = cl.strip()
                                    if cl.startswith("Copyright:"):
                                        cl = cl[len("Copyright:"):].strip()
                                    if cl:
                                        existing_raw.append(cl)
                                # Merge existing + new holders together (dedup + year merge)
                                combined = existing_raw + cleaned
                                merged = merge_copyrights(combined, prefer_unicode=_unicode_pref)
                                # Check if merged result differs from existing
                                old_merged = merge_copyrights(existing_raw, prefer_unicode=_unicode_pref)
                                if merged != old_merged:
                                    old_block = fm.group(0) + cop_m.group(0)
                                    new_cop = "Copyright: " + format_copyright_field(merged)
                                    new_block = fm.group(0) + "\n" + new_cop
                                    working_text = working_text.replace(old_block, new_block, 1)
                                    changes_made += 1
                                    found = True
                                    print(f"  ✓ Added.\n")
                                else:
                                    found = True
                                    print(f"  ✓ Already present.\n")
                            break
                    if not found:
                        print(f"  ✗ Could not locate stanza (edit manually).\n")
                else:
                    print(f"  ✗ Skipped.\n")

        if extra_holders:
            print(f"{len(extra_holders)} copyright holder(s) declared but not detected in source:\n")
            for glob_pat, holder in extra_holders:
                print(f"  Stanza: Files: {glob_pat}")
                print(f"    - {holder}")
            print()
            print("  NOTE: Stale holders are NOT auto-removed — upstream attribution")
            print("  requirements may still apply. Review manually.\n")

        # ── Fix 5: Propose debian/* stanza if missing ────────────────
        # Not applicable for native packages (3.0 (native)).
        # Re-check working_text (not the stale 'stanzas' list) because
        # earlier fixes may have removed a debian/* stanza with Unknown.
        has_debian_glob = bool(
            re.search(r'^Files:[^\n]*\bdebian/\*', working_text, re.MULTILINE)
        )

        # Build packager copyright string from detected identity
        packager_cop = ""
        if packager_name or packager_email:
            parts = []
            if packager_year:
                parts.append(packager_year)
            if packager_name:
                parts.append(packager_name)
            if packager_email:
                parts.append(f"<{packager_email}>")
            packager_cop = " ".join(parts)

        if is_native:
            pass  # native packages don't need a separate debian/* stanza
        elif not has_debian_glob:
            deb_lic = "GPL-2+"
            deb_cop = packager_cop or "FIXME"

            print("No debian/* stanza found — proposing packaging copyright:\n")
            print(f"  ┌──────────────────────────────────────")
            print(f"  │ Files: debian/*")
            print(f"  │ Copyright: {deb_cop}")
            print(f"  │ License: {deb_lic}")
            print(f"  └──────────────────────────────────────")

            answer = ask("  Add this stanza? [Y/n/e(dit)/A/X] ", batch=True).strip().lower()
            if answer in ("e", "edit"):
                print(f"  Enter copyright holder (or press Enter to keep):")
                new_cop = ask(f"    Copyright [{deb_cop}]: ").strip()
                if new_cop:
                    deb_cop = new_cop
                print(f"  Enter license (or press Enter to keep):")
                new_lic = ask(f"    License [{deb_lic}]: ").strip()
                if new_lic:
                    deb_lic = new_lic
                answer = "y"

            if answer not in ("n", "no"):
                stanza = f"\nFiles: debian/*\nCopyright: {deb_cop}\nLicense: {deb_lic}\n"
                pos = find_stanza_insert_pos(working_text)
                working_text = working_text[:pos] + stanza + working_text[pos:]
                changes_made += 1
                print(f"  ✓ debian/* stanza added.\n")
            else:
                print(f"  ✗ Skipped.\n")
        elif packager_cop:
            # debian/* stanza exists — check if packager is listed
            _deb_m = re.search(r'^Files:[^\n]*\b(debian/\*\*?)', working_text, re.MULTILINE)
            deb_glob = _deb_m.group(1) if _deb_m else "debian/*"
            # Extract copyright lines from working_text for the debian/* stanza
            existing_cops = []
            _deb_files_pat = r'(Files:[^\n]*(?:\n +[^\n]+)*)'
            _deb_escaped = re.escape(deb_glob)
            for _dfm in re.finditer(_deb_files_pat, working_text):
                if re.search(r'(?:^Files:\s*|(?<=\n)\s+)' + _deb_escaped + r'\s*$',
                             _dfm.group(0), re.MULTILINE):
                    _drest = working_text[_dfm.end():]
                    _dcop_m = re.match(r'\nCopyright:([^\n]*(?:\n +[^\n]+)*)', _drest)
                    if _dcop_m:
                        for _dcl in _dcop_m.group(0).split('\n'):
                            _dcl = _dcl.strip()
                            if _dcl.startswith("Copyright:"):
                                _dcl = _dcl[len("Copyright:"):].strip()
                            if _dcl:
                                existing_cops.append(_dcl)
                    break
            norm_packager = norm_copyright(packager_cop)
            if norm_packager:
                packager_found = any(
                    nc and (norm_packager in nc or nc in norm_packager)
                    for nc in (norm_copyright(c) for c in existing_cops)
                )
            else:
                packager_found = True  # can't meaningfully compare
            if not packager_found:
                print(f"Current packager not found in debian/* copyright holders:")
                print(f"    {packager_cop}\n")
                answer = ask("  Add yourself to the debian/* stanza? [Y/n/e(dit)/A/X] ", batch=True).strip().lower()
                if answer in ("e", "edit"):
                    print("  Enter copyright line (or press Enter to keep):")
                    new_cop = ask(f"    Copyright [{packager_cop}]: ").strip()
                    if new_cop:
                        packager_cop = new_cop
                    answer = "y"
                if answer not in ("n", "no"):
                    escaped_glob = re.escape(deb_glob)
                    files_pat = r'(Files:[^\n]*(?:\n +[^\n]+)*)'
                    found = False
                    for fm in re.finditer(files_pat, working_text):
                        if re.search(r'(?:^Files:\s*|(?<=\n)\s+)' + escaped_glob + r'\s*$', fm.group(0), re.MULTILINE):
                            rest = working_text[fm.end():]
                            cop_m = re.match(r'(\nCopyright:[^\n]*(?:\n +[^\n]+)*)', rest)
                            if cop_m:
                                # Detect existing indent
                                cop_lines = cop_m.group(0).split('\n')
                                existing_indent = " " * 11
                                for cl in cop_lines[2:]:
                                    m = re.match(r'^( +)', cl)
                                    if m:
                                        existing_indent = m.group(1)
                                        break
                                old = fm.group(0) + cop_m.group(0)
                                new = old + "\n" + _copyright_continuations([packager_cop], indent=existing_indent)
                                working_text = working_text.replace(old, new, 1)
                                changes_made += 1
                                found = True
                                print(f"  ✓ Added to debian/* stanza.\n")
                            else:
                                # Stanza has no Copyright: field — insert one
                                old = fm.group(0)
                                lic_m = re.match(r'(\nLicense:[^\n]*)', rest)
                                if lic_m:
                                    old = old + lic_m.group(0)
                                    new = fm.group(0) + f"\nCopyright: {packager_cop}" + lic_m.group(0)
                                else:
                                    new = old + f"\nCopyright: {packager_cop}"
                                working_text = working_text.replace(old, new, 1)
                                changes_made += 1
                                found = True
                                print(f"  ✓ Added Copyright to debian/* stanza.\n")
                            break
                    if not found:
                        print(f"  ✗ Could not locate stanza (edit manually).\n")
                else:
                    print(f"  ✗ Skipped.\n")
            elif packager_year:
                # Packager is listed — check if current year is present
                cur_yr = int(packager_year)
                matched_line = None
                for ec in existing_cops:
                    nc = norm_copyright(ec)
                    if nc and (norm_packager in nc or nc in norm_packager):
                        matched_line = ec
                        break
                if matched_line:
                    years, author = _parse_copyright(matched_line)
                    if cur_yr not in years:
                        # Build updated line with the current year added
                        years.add(cur_yr)
                        syrs = sorted(years)
                        parts = []
                        start = prev = syrs[0]
                        for y in syrs[1:]:
                            if y == prev + 1:
                                prev = y
                            else:
                                parts.append(f"{start}-{prev}" if prev > start else str(start))
                                start = prev = y
                        parts.append(f"{start}-{prev}" if prev > start else str(start))
                        new_years = ", ".join(parts)
                        updated_line = f"{new_years} {author}"

                        print(f"Current year ({packager_year}) not in debian/* packager copyright:")
                        print(f"    {matched_line}")
                        print(f"  → {updated_line}\n")
                        answer = ask("  Update year? [Y/n/A/X] ", batch=True).strip().lower()
                        if answer not in ("n", "no"):
                            working_text = working_text.replace(matched_line, updated_line, 1)
                            changes_made += 1
                            print(f"  ✓ Year updated.\n")
                        else:
                            print(f"  ✗ Skipped.\n")

        # ── Fix 6: Consolidate stanzas ───────────────────────────────
        # Re-check for consolidation opportunities against working_text
        # (reflects any stanzas added/modified by earlier fixes).
        consol_groups_fix = check_consolidation(working_text)
        if consol_groups_fix:
            total_saving = sum(len(g["stanzas"]) - 1
                               for g in consol_groups_fix)
            print(f"{len(consol_groups_fix)} group(s) of stanzas can be "
                  f"consolidated ({total_saving} stanza(s) merged):\n")

            for grp in consol_groups_fix:
                lic = grp["license"]
                n = len(grp["stanzas"])
                merged_files = grp["merged_files"]
                merged_cops = grp["merged_cops"]

                print(f"  {lic} — {n} stanzas → 1 "
                      f"({len(merged_files)} files)")
                print(f"  Proposed merged stanza:")
                print(f"  ┌──────────────────────────────────────")
                # Show files
                if len(merged_files) <= 5:
                    print(f"  │ Files: {merged_files[0]}")
                    for mf in merged_files[1:]:
                        print(f"  │        {mf}")
                else:
                    print(f"  │ Files: {merged_files[0]}")
                    print(f"  │        {merged_files[1]}")
                    print(f"  │        ... ({len(merged_files)} files total)")
                # Show merged copyright
                for i, cl in enumerate(merged_cops or ["FIXME"]):
                    tag = "Copyright" if i == 0 else "          "
                    print(f"  │ {tag}: {cl}")
                print(f"  │ License: {lic}")
                print(f"  └──────────────────────────────────────")

                # Show what will be replaced
                print(f"\n  Replacing {n} existing stanzas:")
                for ps in grp["stanzas"]:
                    files_preview = " ".join(ps["files"][:2])
                    if len(ps["files"]) > 2:
                        files_preview += f" ... (+{len(ps['files'])-2})"
                    print(f"    • Files: {files_preview}")

                answer = ask("\n  Consolidate these stanzas? [y/N/A/X] ",
                             batch=True).strip().lower()
                if answer in ("y", "yes"):
                    # Build the merged stanza text
                    # Build directory file counts from all stanzas
                    # so dir/* is only used when the merged stanza
                    # owns ALL files in the directory.
                    _fix6_dir_counts = defaultdict(int)
                    for _ps6 in _parse_paragraph_stanzas(
                            working_text):
                        for _f6 in _ps6["files"]:
                            if _f6 == "*" or "*" in _f6:
                                continue
                            _d6 = str(Path(_f6).parent)
                            _fix6_dir_counts[_d6] += 1
                    _fix6_all_files = {r["path"].lstrip("./")
                                       for r in scan_results}
                    simplified = _simplify_file_list(
                        merged_files,
                        all_dir_counts=_fix6_dir_counts,
                        all_files=_fix6_all_files)
                    files_str = format_files_field(simplified)
                    cop_field = format_copyright_field(merged_cops)
                    merged_stanza = (f"{files_str}\n"
                                     f"Copyright: {cop_field}\n"
                                     f"License: {lic}")

                    # Remove old stanzas from working_text and insert
                    # the merged one at the position of the first.
                    first_pos = None
                    for ps in grp["stanzas"]:
                        pt = ps["para_text"]
                        pos = working_text.find(pt)
                        if pos == -1:
                            continue
                        if first_pos is None:
                            first_pos = pos
                            # Replace first occurrence with merged stanza
                            working_text = (working_text[:pos]
                                            + merged_stanza
                                            + working_text[pos + len(pt):])
                        else:
                            # Remove subsequent occurrences
                            # Also remove surrounding blank line
                            before = working_text[:pos]
                            after = working_text[pos + len(pt):]
                            # Strip one leading newline if present
                            if before.endswith("\n\n"):
                                before = before[:-1]
                            working_text = before + after

                    changes_made += 1
                    print(f"  ✓ Consolidated.\n")
                else:
                    print(f"  ✗ Skipped.\n")

        # ── Fix 6b: Collapse explicit file lists into dir/* globs ────
        # When a stanza lists many individual files, replace them with
        # dir/* globs where safe.  DEP-5 uses last-match-wins, so
        # dir/* is safe when every file in that directory that does NOT
        # belong to this stanza is covered by a more-specific stanza.
        _6b_para_stanzas = _parse_paragraph_stanzas(working_text)
        if _6b_para_stanzas:
            # Map every explicit file to its stanza index
            _6b_file_to_stanza = {}  # path → list of stanza indices
            for _si, _ps in enumerate(_6b_para_stanzas):
                for _fp in _ps["files"]:
                    if "*" not in _fp:
                        _6b_file_to_stanza.setdefault(_fp, []).append(_si)

            # Count files per directory across ALL stanzas
            _6b_dir_files = defaultdict(set)  # dir → set of paths
            for _fp in _6b_file_to_stanza:
                _d = str(Path(_fp).parent)
                _6b_dir_files[_d].add(_fp)

            # Also track which dirs have subdirs in the file set
            _6b_all_dirs = set(_6b_dir_files.keys())

            # For each stanza, find dirs where it can use dir/*
            _6b_total_collapsed = 0
            _6b_paras = working_text.split("\n\n")
            for _si, _ps in enumerate(_6b_para_stanzas):
                if _ps["files"] == ["*"]:
                    continue
                explicit = [f for f in _ps["files"] if "*" not in f]
                if len(explicit) < 4:
                    continue  # too few to bother

                # Group this stanza's files by directory
                _my_dirs = defaultdict(list)
                for _fp in explicit:
                    _d = str(Path(_fp).parent)
                    _my_dirs[_d].append(_fp)

                new_patterns = list(_ps["files"])  # start with originals
                collapsed_count = 0
                for _d, _my_files in sorted(_my_dirs.items()):
                    if len(_my_files) < 2:
                        continue
                    if _d == ".":
                        continue
                    # If dir has subdirectories, check whether ALL
                    # subdir files belong to OTHER stanzas (whose
                    # specific paths will override our dir/* glob).
                    has_subdirs = any(
                        od != _d and od.startswith(_d + "/")
                        for od in _6b_all_dirs)
                    if has_subdirs:
                        # Collect all files in subdirectories
                        # (fnmatch * matches /, so dir/* also matches
                        # dir/sub/file — we must verify those files
                        # are still governed by the correct stanza).
                        _subdir_files = set()
                        for _od in _6b_all_dirs:
                            if _od != _d and _od.startswith(_d + "/"):
                                _subdir_files |= _6b_dir_files.get(
                                    _od, set())
                        # Existing globs in our stanza that already
                        # cover subdir files (meaning our stanza
                        # already governs them).
                        _my_globs = [f for f in _ps["files"]
                                     if "*" in f]
                        _subdir_safe = True
                        for _sf in _subdir_files:
                            _sf_stanzas = _6b_file_to_stanza.get(
                                _sf, [])
                            if not _sf_stanzas:
                                _subdir_safe = False
                                break
                            # Already in our stanza explicitly —
                            # dir/* doesn't change governance.
                            if _si in _sf_stanzas:
                                continue
                            # Already matched by a glob in our
                            # stanza — dir/* doesn't change
                            # governance.
                            if any(fnmatch.fnmatch(_sf, g)
                                   for g in _my_globs):
                                continue
                            # A later stanza covers it and will
                            # override our glob (last-match-wins).
                            if max(_sf_stanzas) > _si:
                                continue
                            # Only earlier stanzas cover it — our
                            # new glob would override them.
                            _subdir_safe = False
                            break
                        if not _subdir_safe:
                            continue
                    # Check: all OTHER files in this dir must belong to
                    # other stanzas (they'll override dir/* with their
                    # more-specific paths).
                    all_in_dir = _6b_dir_files.get(_d, set())
                    other_files = all_in_dir - set(_my_files)
                    others_covered = all(
                        _fp in _6b_file_to_stanza
                        for _fp in other_files)
                    if not others_covered:
                        continue
                    # Also skip if dir/* already in patterns
                    glob_pat = _d + "/*"
                    if glob_pat in new_patterns:
                        continue
                    # Replace individual files with dir/*
                    for _fp in _my_files:
                        if _fp in new_patterns:
                            new_patterns.remove(_fp)
                    new_patterns.append(glob_pat)
                    collapsed_count += len(_my_files)

                if collapsed_count >= 2:
                    new_patterns.sort()
                    # Rebuild the Files: field in the paragraph
                    files_str = format_files_field(new_patterns)
                    # Find and replace the original paragraph
                    _orig_para = _ps["para_text"]
                    # Extract the original Files: block
                    _orig_lines = _orig_para.split("\n")
                    _files_start = None
                    _files_end = None
                    for _li, _line in enumerate(_orig_lines):
                        if _line.startswith("Files:"):
                            _files_start = _li
                        elif (_files_start is not None
                              and _files_end is None
                              and not _line.startswith("       ")):
                            _files_end = _li
                            break
                    if _files_start is None:
                        continue
                    if _files_end is None:
                        _files_end = len(_orig_lines)
                    new_lines = (_orig_lines[:_files_start]
                                 + [files_str]
                                 + _orig_lines[_files_end:])
                    new_para = "\n".join(new_lines)
                    pos = working_text.find(_orig_para)
                    if pos >= 0:
                        working_text = (working_text[:pos]
                                        + new_para
                                        + working_text[
                                            pos + len(_orig_para):])
                        _6b_total_collapsed += collapsed_count

            if _6b_total_collapsed:
                changes_made += 1
                print(f"Collapsed {_6b_total_collapsed} explicit file "
                      f"paths into directory globs.\n")

        # ── Fix 7: Normalize copyright lines ─────────────────────────
        # Re-parse all stanzas and rebuild copyright lines with
        # consistent formatting: year before holder, deobfuscated
        # emails, angle brackets instead of parentheses.
        fix7_count = 0
        for para in working_text.split("\n\n"):
            lines = para.split("\n")
            has_files = any(l.strip().startswith("Files:") for l in lines)
            if not has_files:
                continue
            # Collect raw copyright lines from the stanza
            cop_lines_raw = []
            in_cop = False
            for line in lines:
                if line.startswith("Copyright:"):
                    val = line[10:].strip()
                    if val:
                        cop_lines_raw.append(val)
                    in_cop = True
                elif in_cop and line.startswith(" ") and line.strip():
                    # Continuation lines: may start with ": " (double Copyright:)
                    val = line.strip()
                    if val.startswith(": "):
                        val = val[2:].strip()
                    if val.startswith("Copyright:"):
                        val = val[10:].strip()
                    if val:
                        cop_lines_raw.append(val)
                else:
                    in_cop = False

            if not cop_lines_raw:
                continue

            # Build the old copyright block exactly as it appears
            old_cop_parts = []
            in_cop = False
            for line in lines:
                if line.startswith("Copyright:"):
                    old_cop_parts.append(line)
                    in_cop = True
                elif in_cop and line.startswith(" ") and line.strip():
                    old_cop_parts.append(line)
                else:
                    in_cop = False
            old_cop_block = "\n".join(old_cop_parts)

            # Merge through our normalizer (handles year ordering,
            # email deobfuscation, bracket fixes, dedup)
            merged = merge_copyrights(cop_lines_raw, prefer_unicode=_unicode_pref)
            new_cop_block = "Copyright: " + format_copyright_field(merged)

            if old_cop_block != new_cop_block:
                working_text = working_text.replace(old_cop_block, new_cop_block, 1)
                fix7_count += 1

        if fix7_count:
            changes_made += 1
            print(f"Normalized copyright lines in {fix7_count} stanza(s).\n")

        # ── Fix 7b: Normalize license names ──────────────────────────
        # Decopy and other tools sometimes emit non-canonical license
        # names (e.g. "CC-BY-SA-3" instead of "CC-BY-SA-3.0").
        # Normalize all License: fields in both Files stanzas and
        # standalone License blocks using to_dep5().
        fix7b_count = 0
        new_paras = []
        for para in working_text.split("\n\n"):
            plines = para.split("\n")
            changed = False
            new_lines = []
            for line in plines:
                if line.startswith("License:"):
                    lic_val = line[8:].strip()
                    lic_name = lic_val.split()[0] if lic_val else None
                    if lic_name:
                        norm = to_dep5(lic_name)
                        if norm != lic_name:
                            line = "License: " + norm + lic_val[len(lic_name):]
                            changed = True
                new_lines.append(line)
            if changed:
                fix7b_count += 1
            new_paras.append("\n".join(new_lines))
        if fix7b_count:
            working_text = "\n\n".join(new_paras)
            changes_made += 1
            print(f"Normalized license names in {fix7b_count} paragraph(s).\n")

        # ── Fix 7c: Strip stale "No explicit license found" comments ─
        # Decopy adds "Comment: No explicit license found, using
        # license(s) from: ..." when a file inherits its license from
        # a nearby LICENSE/COPYING file.  Once the license is resolved,
        # this comment is noise.
        fix7c_count = 0
        new_paras = []
        for para in working_text.split("\n\n"):
            plines = para.split("\n")
            has_license = any(
                l.startswith("License:") and l[8:].strip()
                and l[8:].strip().split()[0] not in ("UNKNOWN", "FIXME")
                for l in plines)
            if not has_license:
                new_paras.append(para)
                continue
            # Remove Comment: block about inherited license
            filtered = []
            skip_cont = False
            for line in plines:
                if line.startswith("Comment:") and "no explicit license found" in line.lower():
                    skip_cont = True
                    continue
                if skip_cont and line.startswith(" ") and line.strip():
                    continue
                skip_cont = False
                filtered.append(line)
            if len(filtered) < len(plines):
                fix7c_count += 1
                new_paras.append("\n".join(filtered))
            else:
                new_paras.append(para)
        if fix7c_count:
            working_text = "\n\n".join(new_paras)
            changes_made += 1
            print(f"Removed stale 'No explicit license found' comment "
                  f"from {fix7c_count} stanza(s).\n")

        # ── Fix 7d: Remove orphaned standalone License blocks ────────
        # After stanza license corrections (Fix 1c) or consolidation
        # (Fix 6), standalone License: blocks may remain that are no
        # longer referenced by any Files stanza.
        _referenced_lics = set()
        for para in working_text.split("\n\n"):
            plines = para.strip().split("\n")
            has_files = any(l.strip().startswith("Files:") for l in plines)
            if has_files:
                for l in plines:
                    if l.startswith("License:"):
                        lic = l[8:].strip().split()[0] if l[8:].strip() else None
                        if lic:
                            _referenced_lics.add(lic)

        fix7d_count = 0
        new_paras = []
        for para in working_text.split("\n\n"):
            plines = para.strip().split("\n")
            if not plines:
                new_paras.append(para)
                continue
            first = plines[0]
            has_files = any(l.strip().startswith("Files:") for l in plines)
            if first.startswith("License:") and not has_files:
                lic_name = first[8:].strip().split()[0] if first[8:].strip() else None
                if lic_name and lic_name not in _referenced_lics:
                    fix7d_count += 1
                    print(f"  Removed orphaned License block: {lic_name}")
                    continue
            new_paras.append(para)
        if fix7d_count:
            working_text = "\n\n".join(new_paras)
            changes_made += 1
            print()

        # ── Fix 8: Replace inline license text for common licenses ───
        # When a Files stanza has the full license body inline and
        # the license is in /usr/share/common-licenses/, strip the
        # inline text and ensure a standalone License: block exists.
        _COMMON_LICENSES_FIX8 = {
            "GPL-1", "GPL-1+", "GPL-2", "GPL-2+", "GPL-3", "GPL-3+",
            "LGPL-2", "LGPL-2+", "LGPL-2.1", "LGPL-2.1+", "LGPL-3", "LGPL-3+",
            "AGPL-3", "AGPL-3+",
            "Apache-2.0", "MPL-1.1", "MPL-2.0",
            "GFDL", "GFDL-1.1", "GFDL-1.1+", "GFDL-1.2", "GFDL-1.2+",
            "GFDL-1.3", "GFDL-1.3+",
            "Artistic", "BSD", "CC0-1.0",
        }
        fix8_count = 0
        # Find file stanzas with inline license body for common licenses
        new_paras = []
        standalone_needed = set()
        for para in working_text.split("\n\n"):
            lines = para.split("\n")
            has_files = any(l.strip().startswith("Files:") for l in lines)
            if not has_files:
                new_paras.append(para)
                continue
            # Find the License: line and check for inline body
            lic_idx = None
            lic_name = None
            for idx, line in enumerate(lines):
                if line.startswith("License:"):
                    lic_val = line[8:].strip()
                    lic_name = lic_val.split()[0] if lic_val else None
                    lic_idx = idx
                    break
            if lic_idx is None or lic_name is None:
                new_paras.append(para)
                continue
            # Check if there are continuation lines after License:
            has_body = (lic_idx + 1 < len(lines)
                        and lines[lic_idx + 1].startswith(" "))
            if not has_body:
                new_paras.append(para)
                continue
            # Only strip if it's a common license
            if (lic_name not in _COMMON_LICENSES_FIX8
                    and lic_name not in SYSTEM_COMMON_LICENSES):
                new_paras.append(para)
                continue
            # Strip the inline body (continuation lines after License:)
            trimmed = lines[:lic_idx + 1]
            fix8_count += 1
            standalone_needed.add(lic_name)
            new_paras.append("\n".join(trimmed))

        if fix8_count:
            working_text = "\n\n".join(new_paras)
            changes_made += 1
            # Ensure standalone License: blocks exist for stripped licenses
            for lic in sorted(standalone_needed):
                # Check if a standalone block already exists
                has_block = False
                for block_para in working_text.split("\n\n"):
                    block_lines = block_para.strip().split("\n")
                    if not block_lines:
                        continue
                    first = block_lines[0]
                    has_f = any(l.strip().startswith("Files:")
                               for l in block_lines)
                    if (first.startswith("License:") and not has_f
                            and first[8:].strip().split()[0] == lic):
                        has_block = True
                        break
                if not has_block:
                    body = _dep5_common_license_body(lic)
                    block = f"\nLicense: {lic}\n{body}\n"
                    working_text = working_text.rstrip("\n") + "\n" + block
            print(f"Replaced inline license text with common-licenses"
                  f" reference in {fix8_count} stanza(s).\n")

        # ── Fix 0c2-final: Resolve remaining Copyright: FIXME ────────
        # Fix steps after Fix 0c2 (Fix 1, 3, 6 …) may create stanzas
        # with Copyright: FIXME when scanner data was unavailable at
        # that point.  Try harder approaches now that we have all data:
        #   1. fnmatch scanner data lookup (handles path format differences)
        #   2. Read actual file headers for Copyright:/Author: lines
        #   3. Check directory-level COPYRIGHT/COPYING/AUTHORS files
        _0c2f_paras = working_text.split("\n\n")
        _0c2f_count = 0
        _src_root = os.environ.get("SRCDIR", ".")
        _cop_header_re = re.compile(
            r'(?:copyright|©|\(c\))\s*'
            r'(?:\d{4}[\s,\-]*)*\s*(.+)',
            re.IGNORECASE)
        _author_header_re = re.compile(
            r'^\s*(?://|#|/\*|\*|;)\s*'
            r'(?:Author|@author)\s*:\s*(.+)',
            re.IGNORECASE)
        for _pi, _para in enumerate(_0c2f_paras):
            if not re.match(r'^\s*Files:', _para):
                continue
            if not re.search(r'^Copyright:\s*FIXME\s*$', _para,
                             re.MULTILINE):
                continue

            # Extract file patterns from the stanza
            _stanza_lines = _para.split("\n")
            _stanza_files = []
            _files_end = 0
            for _li, _line in enumerate(_stanza_lines):
                if _line.startswith("Files:"):
                    rest = _line[6:].strip()
                    if rest:
                        _stanza_files.extend(rest.split())
                    _files_end = _li + 1
                elif _files_end and _line.startswith("       "):
                    _stanza_files.extend(_line.strip().split())
                    _files_end = _li + 1
                else:
                    break

            _lic_m = re.search(r'^License:\s*(.+)', _para,
                               re.MULTILINE)
            _is_gpl = bool(
                _lic_m and re.search(
                    r'\b(GPL|LGPL|AGPL|GFDL)\b',
                    _lic_m.group(1), re.IGNORECASE))
            _fsf_re = re.compile(
                r'free\s+software\s+foundation', re.IGNORECASE)

            # Strategy 1: fnmatch scanner data
            _inferred = []
            for r in scan_results:
                fp = r["path"].lstrip("./")
                for pat in _stanza_files:
                    if fnmatch.fnmatch(fp, pat):
                        for c in r.get("copyrights", []):
                            if c and not (_is_gpl
                                          and _fsf_re.search(c)):
                                _inferred.append(c)
                        break

            # Strategy 2: Read file headers (first 30 lines)
            if not _inferred:
                _sample_files = []
                for pat in _stanza_files:
                    if "*" in pat or "?" in pat:
                        matches = sorted(
                            globmod.glob(
                                os.path.join(_src_root, pat)))
                        # Glob may return dirs; descend to find files
                        for _gm in matches:
                            if os.path.isfile(_gm):
                                _sample_files.append(_gm)
                            elif os.path.isdir(_gm):
                                for _df in sorted(
                                        os.listdir(_gm)):
                                    _dfp = os.path.join(_gm, _df)
                                    if os.path.isfile(_dfp):
                                        _sample_files.append(_dfp)
                    else:
                        _fp = os.path.join(_src_root, pat)
                        if os.path.isfile(_fp):
                            _sample_files.append(_fp)
                # Prioritize source-like files over binary/images
                _SRC_EXT = {'.js', '.py', '.c', '.cpp', '.h', '.rb',
                            '.pl', '.sh', '.java', '.rs', '.go',
                            '.css', '.xsl', '.xml', '.html'}
                _sample_files.sort(
                    key=lambda p: (
                        0 if os.path.splitext(p)[1].lower()
                        in _SRC_EXT else 1, p))
                for _sf in _sample_files[:15]:
                    try:
                        with open(_sf, 'r', errors='ignore') as _fh:
                            _line_count = 0
                            for _ln in _fh:
                                _line_count += 1
                                if _line_count > 30:
                                    break
                                # Check for copyright statement
                                _cm = _cop_header_re.search(_ln)
                                if _cm:
                                    _val = _ln.strip()
                                    _val = re.sub(
                                        r'^(?://|#|/\*|\*)\s*',
                                        '', _val)
                                    _val = re.sub(
                                        r'\s*\*/\s*$', '', _val)
                                    if (_val
                                            and 'FIXME' not in _val
                                            and 'TODO' not in _val):
                                        _inferred.append(_val)
                                # Check for Author: header
                                _am = _author_header_re.match(_ln)
                                if _am:
                                    _auth = _am.group(1).strip()
                                    if _auth:
                                        _inferred.append(_auth)
                    except (OSError, UnicodeDecodeError):
                        pass
                    if _inferred:
                        break

            # Strategy 3: Directory COPYRIGHT/COPYING/AUTHORS files
            if not _inferred:
                for pat in _stanza_files:
                    # Extract directory: for "dir/*" use "dir",
                    # for specific files use their parent dir
                    if pat.endswith("/*"):
                        _dir = pat[:-2]
                    elif "*" in pat or "?" in pat:
                        _dir = os.path.dirname(
                            pat.split("*")[0].split("?")[0])
                    else:
                        _dir = os.path.dirname(pat)
                    if not _dir:
                        continue
                    for _cfn in ("COPYRIGHT", "COPYING",
                                 "LICENSE", "AUTHORS"):
                        _cfp = os.path.join(
                            _src_root, _dir, _cfn)
                        if not os.path.isfile(_cfp):
                            continue
                        try:
                            with open(_cfp, 'r',
                                      errors='ignore') as _cfh:
                                _lines = _cfh.readlines()
                            # Pass 1: standard copyright regex
                            for _ln in _lines[:50]:
                                _cm = _cop_header_re.search(_ln)
                                if _cm:
                                    _val = _ln.strip()
                                    if (_val
                                            and 'FIXME' not in _val):
                                        _inferred.append(_val)
                            # Pass 2: broader heuristics for
                            # COPYRIGHT/AUTHORS files (e.g. "from
                            # Geonames.org" or "by Jane Doe")
                            if (not _inferred
                                    and _cfn in (
                                        "COPYRIGHT", "AUTHORS")):
                                for _ln in _lines[:50]:
                                    _fm = re.search(
                                        r'\bfrom\s+'
                                        r'(\S+\.(?:org|com|io|net'
                                        r'|edu))\b',
                                        _ln, re.I)
                                    if _fm:
                                        _inferred.append(
                                            _fm.group(1))
                        except OSError:
                            pass
                    if _inferred:
                        break

            # Strategy 4: XML metadata & path-based attribution
            if not _inferred:
                for pat in _stanza_files:
                    # OpenStreetMap data in path → standard attribution
                    if 'openstreetmap' in pat.lower():
                        _inferred.append(
                            "OpenStreetMap contributors")
                        break
                    # XML files: parse for developer_name / author
                    _xml_paths = []
                    if pat.endswith('.xml'):
                        _xp = os.path.join(_src_root, pat)
                        if os.path.isfile(_xp):
                            _xml_paths.append(_xp)
                    elif '*' in pat:
                        _xml_paths = [
                            p for p in sorted(
                                globmod.glob(
                                    os.path.join(_src_root, pat)))
                            if p.endswith('.xml')
                            and os.path.isfile(p)][:3]
                    for _xp in _xml_paths:
                        try:
                            _tree = ET.parse(_xp)
                            _xroot = _tree.getroot()
                            for _el in _xroot.iter():
                                _ltag = re.sub(
                                    r'\{[^}]*\}', '', _el.tag)
                                if _ltag == 'developer_name':
                                    # Use untranslated (no xml:lang)
                                    _xlang = _el.get(
                                        '{http://www.w3.org/XML/'
                                        '1998/namespace}lang')
                                    if not _xlang:
                                        _val = (
                                            _el.text or '').strip()
                                        if _val:
                                            _inferred.append(_val)
                                            break
                        except (ET.ParseError, OSError):
                            pass
                        if _inferred:
                            break
                    if _inferred:
                        break

            merged = merge_copyrights(_inferred)
            if not merged:
                continue

            cop_field = format_copyright_field(merged)
            new_lines = []
            for _line in _stanza_lines:
                if re.match(r'^Copyright:\s*FIXME\s*$', _line):
                    new_lines.append(f"Copyright: {cop_field}")
                else:
                    new_lines.append(_line)
            _0c2f_paras[_pi] = "\n".join(new_lines)
            _0c2f_count += 1

        if _0c2f_count:
            working_text = "\n\n".join(_0c2f_paras)
            changes_made += 1
            print(f"  Resolved {_0c2f_count} remaining Copyright:"
                  f" FIXME stanza(s).\n")

        # ── Fix 9: Split over-broad glob attribution ─────────────────
        # When a dir/* (or similar glob) stanza declares a copyright
        # holder that was only detected in a subset of the matching
        # files, narrow the stanza so the holder only covers files
        # they actually authored.
        #
        # Single-holder stanzas: replace the broad globs with specific
        # patterns matching only the detected files (or remove the
        # stanza if the holder isn't detected in any matching files).
        #
        # Multi-holder stanzas: remove minority holders from the broad
        # stanza and create a new specific stanza for their files.
        # DEP-5 last-match-wins: the specific stanza comes AFTER the
        # broad one so it overrides for the relevant files.

        def _f9_build_globs(subset_files, governed_set):
            """Build prefix-based or extension-based globs for a file
            subset within a governed set, falling back to individual
            paths."""
            _dirs = defaultdict(list)
            for _fp in subset_files:
                _dirs[str(Path(_fp).parent)].append(_fp)
            _globs = []
            _subset_set = set(subset_files)
            for _d, _dfiles in sorted(_dirs.items()):
                if _d == "." or len(_dfiles) < 2:
                    _globs.extend(sorted(_dfiles))
                    continue
                # Try prefix-based glob first
                basenames = [os.path.basename(f) for f in _dfiles]
                cpfx = os.path.commonprefix(basenames)
                if len(cpfx) > 3:
                    _proposed = (_d + "/" + cpfx + "*"
                                 if _d != "." else cpfx + "*")
                    _collateral = [
                        f for f in governed_set - _subset_set
                        if fnmatch.fnmatch(f, _proposed)]
                    if not _collateral:
                        _globs.append(_proposed)
                        continue
                # Prefix failed or too short — try extension-based
                _ext_groups = defaultdict(list)
                _remaining = []
                for fp in _dfiles:
                    ext = Path(fp).suffix.lower()
                    if ext:
                        _ext_groups[ext].append(fp)
                    else:
                        _remaining.append(fp)
                _ext_used = False
                for ext, ext_files in sorted(_ext_groups.items()):
                    if len(ext_files) < 2:
                        _remaining.extend(ext_files)
                        continue
                    _proposed = _d + "/*" + ext
                    _collateral = [
                        f for f in governed_set - _subset_set
                        if fnmatch.fnmatch(f, _proposed)]
                    if not _collateral:
                        _globs.append(_proposed)
                        _ext_used = True
                    else:
                        _remaining.extend(ext_files)
                if _ext_used or _remaining:
                    _globs.extend(sorted(_remaining))
                else:
                    _globs.extend(sorted(_dfiles))
            return sorted(_globs)

        _f9_para_stanzas = _parse_paragraph_stanzas(working_text)
        _f9_splits = 0
        for _ps in _f9_para_stanzas:
            # Only process stanzas whose Files: field contains a glob
            if not any("*" in f for f in _ps["files"]):
                continue
            if _ps["files"] == ["*"]:
                continue

            # Collect all scanned files governed by this stanza's globs
            _governed = set()
            for r in scan_results:
                fp = r["path"].lstrip("./")
                if any(fnmatch.fnmatch(fp, g) for g in _ps["files"]):
                    _governed.add(fp)
            if len(_governed) < 2:
                continue

            # For each declared holder, find which governed files have
            # that holder detected by the scanner
            _holder_files = {}  # norm → set of filepaths
            _holder_raw = {}    # norm → raw declared string
            for cop in _ps["cops"]:
                nc = norm_copyright(cop)
                if not nc:
                    continue
                _holder_raw[nc] = cop
                _holder_files[nc] = set()

            for fp in _governed:
                for r in scan_results:
                    rp = r["path"].lstrip("./")
                    if rp != fp:
                        continue
                    for c in r.get("copyrights", []):
                        if not c:
                            continue
                        nc_det = norm_copyright(c)
                        if not nc_det:
                            continue
                        for nc_decl in _holder_files:
                            if nc_det in nc_decl or nc_decl in nc_det:
                                _holder_files[nc_decl].add(fp)
                    break

            # Classify holders as universal (all files) or minority
            _universal = set()
            _minority = {}  # norm → set of files
            for nc, files in _holder_files.items():
                if len(files) >= len(_governed):
                    _universal.add(nc)
                elif len(files) > 0:
                    _minority[nc] = files
                # holders with 0 detected files: scanner may not embed
                # headers in every file — leave as-is (no evidence to
                # narrow)

            if not _minority:
                continue

            # ── Single-holder stanza: narrow the globs ───────────────
            if len(_ps["cops"]) == 1:
                nc = list(_holder_files.keys())[0]
                detected_files = _holder_files.get(nc, set())
                if not detected_files:
                    continue
                if len(detected_files) >= len(_governed):
                    continue  # holder is in all files — no narrowing
                subset_files = sorted(detected_files)
                globs = _f9_build_globs(subset_files, _governed)

                print(f"  Stanza '{' '.join(_ps['files'])}' attributes "
                      f"holder to {len(_governed)} files, but scanner "
                      f"detected them in only {len(subset_files)}:")
                print(f"    ↳ {_ps['cops'][0]}")
                print(f"  Proposing narrowed stanza:\n")
                print(f"  ┌──────────────────────────────────────")
                if len(globs) <= 3:
                    print(f"  │ Files: {globs[0]}")
                    for g in globs[1:]:
                        print(f"  │        {g}")
                else:
                    print(f"  │ Files: {globs[0]}")
                    print(f"  │        {globs[1]}")
                    print(f"  │        ... ({len(globs)} total)")
                for i, cl in enumerate(_ps["cops"]):
                    tag = "Copyright" if i == 0 else "          "
                    print(f"  │ {tag}: {cl}")
                print(f"  │ License: {_ps['license']}")
                print(f"  └──────────────────────────────────────")

                answer = ask(
                    "  Narrow this stanza? [y/N/A/X] ",
                    batch=True).strip().lower()
                if answer in ("y", "yes"):
                    _orig_para = _ps["para_text"]
                    # Replace the Files: field with narrowed globs
                    files_str = format_files_field(globs)
                    _new_para = re.sub(
                        r'Files:[^\n]*(?:\n[ \t]+[^\n]+)*',
                        files_str,
                        _orig_para, count=1)
                    _pos = working_text.find(_orig_para)
                    if _pos >= 0:
                        working_text = (
                            working_text[:_pos]
                            + _new_para
                            + working_text[_pos + len(_orig_para):])
                        _ps["para_text"] = _new_para
                        _f9_splits += 1
                        changes_made += 1
                        print(f"  ✓ Narrowed.\n")
                    else:
                        print(f"  ✗ Could not locate stanza.\n")
                else:
                    print(f"  ✗ Skipped.\n")
                continue

            # ── Multi-holder stanza: split minority into specific ─────
            # Group minority holders by the exact set of files they
            # appear in, so holders sharing the same file subset get
            # one stanza rather than one each.
            _subset_groups = defaultdict(list)  # frozenset(files) → [nc]
            for nc, files in _minority.items():
                _subset_groups[frozenset(files)].append(nc)

            for _file_set, _ncs in sorted(
                    _subset_groups.items(),
                    key=lambda kv: sorted(kv[0])[0]):
                subset_files = sorted(_file_set)
                # Build the copyright lines for the new specific stanza:
                # universal holders + minority holders for this subset
                new_cops_raw = []
                for nc in sorted(_universal):
                    new_cops_raw.append(_holder_raw[nc])
                for nc in _ncs:
                    new_cops_raw.append(_holder_raw[nc])
                merged_cops = merge_copyrights(
                    new_cops_raw, file_paths=subset_files,
                    prefer_unicode=_unicode_pref)
                cop_field = format_copyright_field(merged_cops)

                globs = _f9_build_globs(subset_files, _governed)

                print(f"  Stanza '{' '.join(_ps['files'])}' attributes "
                      f"holder(s) to all files, but scanner detected "
                      f"them in only {len(subset_files)}/"
                      f"{len(_governed)} files.")
                for nc in _ncs:
                    print(f"    ↳ {_holder_raw[nc]}")
                print(f"  Proposing specific stanza for those files:\n")
                print(f"  ┌──────────────────────────────────────")
                if len(globs) <= 3:
                    print(f"  │ Files: {globs[0]}")
                    for g in globs[1:]:
                        print(f"  │        {g}")
                else:
                    print(f"  │ Files: {globs[0]}")
                    print(f"  │        {globs[1]}")
                    print(f"  │        ... ({len(globs)} total)")
                for i, cl in enumerate(merged_cops):
                    tag = "Copyright" if i == 0 else "          "
                    print(f"  │ {tag}: {cl}")
                print(f"  │ License: {_ps['license']}")
                print(f"  └──────────────────────────────────────")

                answer = ask(
                    "  Split this holder into a specific stanza? "
                    "[y/N/A/X] ", batch=True).strip().lower()
                if answer in ("y", "yes"):
                    # 1) Remove minority holders from the broad stanza
                    _orig_para = _ps["para_text"]
                    _broad_cops = [
                        c for c in _ps["cops"]
                        if norm_copyright(c) not in set(_ncs)]
                    if _broad_cops:
                        _broad_cop_field = format_copyright_field(
                            merge_copyrights(
                                _broad_cops,
                                prefer_unicode=_unicode_pref))
                        _new_para = re.sub(
                            r'Copyright:[^\n]*(?:\n[ \t]+[^\n]+)*',
                            'Copyright: ' + _broad_cop_field,
                            _orig_para, count=1)
                    else:
                        _new_para = _orig_para

                    # Replace original paragraph → trimmed paragraph
                    _pos = working_text.find(_orig_para)
                    if _pos >= 0:
                        working_text = (
                            working_text[:_pos]
                            + _new_para
                            + working_text[_pos + len(_orig_para):])
                        _ps["para_text"] = _new_para
                        _ps["cops"] = _broad_cops

                    # 2) Insert the specific stanza AFTER the broad one
                    #    (DEP-5 last-match-wins)
                    files_str = format_files_field(globs)
                    specific_stanza = (
                        f"\n{files_str}\n"
                        f"Copyright: {cop_field}\n"
                        f"License: {_ps['license']}\n")
                    _insert_pos = working_text.find(
                        _ps["para_text"])
                    if _insert_pos >= 0:
                        _insert_pos += len(_ps["para_text"])
                        working_text = (
                            working_text[:_insert_pos]
                            + "\n" + specific_stanza
                            + working_text[_insert_pos:])

                    _f9_splits += 1
                    changes_made += 1
                    print(f"  ✓ Split.\n")
                else:
                    print(f"  ✗ Skipped.\n")

        if _f9_splits:
            print(f"Split/narrowed {_f9_splits} over-broad "
                  f"attribution(s).\n")

        # ── Fix 9b: Widen under-broad glob attribution ───────────────
        # When a stanza declares a copyright holder that was also
        # detected in files NOT covered by the stanza's globs, widen
        # the stanza (or insert an additional stanza) so the holder is
        # attributed to all files where they were actually detected.
        #
        # Single-holder stanzas: add uncovered file globs to the
        # existing Files: field.
        #
        # Multi-holder stanzas: insert a new specific stanza covering
        # the uncovered files with just the under-broad holder.

        def _f9b_build_globs(subset_files, existing_files=None):
            """Build prefix-based or extension-based globs for
            uncovered files."""
            _dirs = defaultdict(list)
            for _fp in subset_files:
                _dirs[str(Path(_fp).parent)].append(_fp)
            _globs = []
            # Compute all file set for collateral check
            _all_known = set(subset_files)
            if existing_files:
                _all_known |= set(existing_files)
            for _d, _dfiles in sorted(_dirs.items()):
                if _d == "." or len(_dfiles) < 2:
                    _globs.extend(sorted(_dfiles))
                    continue
                # Try prefix-based glob first
                basenames = [os.path.basename(f) for f in _dfiles]
                cpfx = os.path.commonprefix(basenames)
                if len(cpfx) > 3:
                    _proposed = (_d + "/" + cpfx + "*"
                                 if _d != "." else cpfx + "*")
                    _collateral = [
                        r["path"].lstrip("./") for r in scan_results
                        if fnmatch.fnmatch(r["path"].lstrip("./"),
                                           _proposed)
                        and r["path"].lstrip("./") not in _all_known]
                    if not _collateral:
                        _globs.append(_proposed)
                        continue
                # Prefix failed or too short — try extension-based
                _ext_groups = defaultdict(list)
                _remaining = []
                for fp in _dfiles:
                    ext = Path(fp).suffix.lower()
                    if ext:
                        _ext_groups[ext].append(fp)
                    else:
                        _remaining.append(fp)
                _ext_used = False
                for ext, ext_files in sorted(_ext_groups.items()):
                    if len(ext_files) < 2:
                        _remaining.extend(ext_files)
                        continue
                    _proposed = _d + "/*" + ext
                    _collateral = [
                        r["path"].lstrip("./") for r in scan_results
                        if fnmatch.fnmatch(r["path"].lstrip("./"),
                                           _proposed)
                        and r["path"].lstrip("./") not in _all_known]
                    if not _collateral:
                        _globs.append(_proposed)
                        _ext_used = True
                    else:
                        _remaining.extend(ext_files)
                if _ext_used or _remaining:
                    _globs.extend(sorted(_remaining))
                else:
                    _globs.extend(sorted(_dfiles))
            return sorted(_globs)

        _f9b_issues = check_under_broad_attribution(working_text)
        _f9b_widens = 0
        for _ub in _f9b_issues:
            _ps = _ub["para"]
            uncovered = _ub["uncovered_files"]
            globs = _f9b_build_globs(uncovered, _ub["stanza_files"])

            if len(_ps["cops"]) == 1:
                # Single-holder: widen the existing Files: field
                all_globs = sorted(set(_ps["files"] + globs))
                print(f"  Stanza '{' '.join(_ps['files'])}' declares "
                      f"holder detected in {len(uncovered)} additional "
                      f"file(s):")
                print(f"    ↳ {_ub['holder_raw']}")
                print(f"  Proposing widened stanza:\n")
                print(f"  ┌──────────────────────────────────────")
                if len(all_globs) <= 3:
                    print(f"  │ Files: {all_globs[0]}")
                    for g in all_globs[1:]:
                        print(f"  │        {g}")
                else:
                    print(f"  │ Files: {all_globs[0]}")
                    print(f"  │        {all_globs[1]}")
                    print(f"  │        ... ({len(all_globs)} total)")
                for i, cl in enumerate(_ps["cops"]):
                    tag = "Copyright" if i == 0 else "          "
                    print(f"  │ {tag}: {cl}")
                print(f"  │ License: {_ps['license']}")
                print(f"  └──────────────────────────────────────")

                answer = ask(
                    "  Widen this stanza? [y/N/A/X] ",
                    batch=True).strip().lower()
                if answer in ("y", "yes"):
                    _orig_para = _ps["para_text"]
                    files_str = format_files_field(all_globs)
                    _new_para = re.sub(
                        r'Files:[^\n]*(?:\n[ \t]+[^\n]+)*',
                        files_str,
                        _orig_para, count=1)
                    _pos = working_text.find(_orig_para)
                    if _pos >= 0:
                        working_text = (
                            working_text[:_pos]
                            + _new_para
                            + working_text[_pos + len(_orig_para):])
                        _ps["para_text"] = _new_para
                        _ps["files"] = all_globs
                        _f9b_widens += 1
                        changes_made += 1
                        print(f"  ✓ Widened.\n")
                    else:
                        print(f"  ✗ Could not locate stanza.\n")
                else:
                    print(f"  ✗ Skipped.\n")
            else:
                # Multi-holder: insert a new stanza for the uncovered
                # files with just this holder + same license
                cop_field = format_copyright_field(
                    merge_copyrights(
                        [_ub["holder_raw"]],
                        file_paths=uncovered,
                        prefer_unicode=_unicode_pref))

                print(f"  Stanza '{' '.join(_ps['files'])}' declares "
                      f"holder detected in {len(uncovered)} additional "
                      f"file(s) not covered by the stanza:")
                print(f"    ↳ {_ub['holder_raw']}")
                print(f"  Proposing additional stanza:\n")
                print(f"  ┌──────────────────────────────────────")
                if len(globs) <= 3:
                    print(f"  │ Files: {globs[0]}")
                    for g in globs[1:]:
                        print(f"  │        {g}")
                else:
                    print(f"  │ Files: {globs[0]}")
                    print(f"  │        {globs[1]}")
                    print(f"  │        ... ({len(globs)} total)")
                print(f"  │ Copyright: {cop_field}")
                print(f"  │ License: {_ps['license']}")
                print(f"  └──────────────────────────────────────")

                answer = ask(
                    "  Add this stanza? [y/N/A/X] ",
                    batch=True).strip().lower()
                if answer in ("y", "yes"):
                    files_str = format_files_field(globs)
                    new_stanza = (
                        f"\n{files_str}\n"
                        f"Copyright: {cop_field}\n"
                        f"License: {_ps['license']}\n")
                    _insert_pos = working_text.find(_ps["para_text"])
                    if _insert_pos >= 0:
                        _insert_pos += len(_ps["para_text"])
                        working_text = (
                            working_text[:_insert_pos]
                            + "\n" + new_stanza
                            + working_text[_insert_pos:])
                        _f9b_widens += 1
                        changes_made += 1
                        print(f"  ✓ Added.\n")
                    else:
                        print(f"  ✗ Could not locate stanza.\n")
                else:
                    print(f"  ✗ Skipped.\n")

        if _f9b_widens:
            print(f"Widened/added {_f9b_widens} under-broad "
                  f"attribution(s).\n")

        # ── Write result ─────────────────────────────────────────────
        if changes_made > 0:
            # Deduplicate standalone License: blocks (keep first occurrence)
            _seen_lic_blocks = set()
            _dedup_paras = []
            _dedup_count = 0
            for para in working_text.split("\n\n"):
                plines = para.strip().split("\n")
                if plines:
                    first = plines[0]
                    has_files = any(
                        l.strip().startswith("Files:")
                        for l in plines)
                    if (first.startswith("License:")
                            and not has_files):
                        lic_name = (
                            first[8:].strip().split()[0]
                            if first[8:].strip() else None)
                        if lic_name and lic_name in _seen_lic_blocks:
                            _dedup_count += 1
                            continue
                        if lic_name:
                            _seen_lic_blocks.add(lic_name)
                _dedup_paras.append(para)
            if _dedup_count:
                working_text = "\n\n".join(_dedup_paras)
                print(f"Removed {_dedup_count} duplicate standalone "
                      f"License block(s).")

            # Clean up any double-blank-lines introduced
            while "\n\n\n" in working_text:
                working_text = working_text.replace("\n\n\n", "\n\n")
            working_text = working_text.rstrip("\n") + "\n"

            print(f"\n{changes_made} fix(es) ready to apply.")
            answer = ask(f"Write changes to {copyright_file}? [y/N] ").strip().lower()
            if answer in ("y", "yes"):
                if not _decopy_generated:
                    # Write backup (skip in generate mode — file was just created)
                    backup = copyright_file + ".bak"
                    with open(backup, "w", encoding="utf-8") as f:
                        f.write(original_text)
                with open(copyright_file, "w", encoding="utf-8") as f:
                    f.write(working_text)
                print(f"\n  ✓ Written to {copyright_file}")
                if not _decopy_generated:
                    print(f"  ✓ Backup saved to {backup}")
                _warn_stubs(working_text, copyright_file)
            else:
                # Offer to write to a different file
                alt = ask("  Write to alternate file instead? [path or Enter to discard] ").strip()
                if alt:
                    with open(alt, "w", encoding="utf-8") as f:
                        f.write(working_text)
                    print(f"  ✓ Written to {alt}")
                    _warn_stubs(working_text, alt)
                else:
                    print("  Changes discarded.")
        else:
            print("No fixes applied.")
        print()

    # ── Decopy-generated: report where the hardened file was written ───
    if _decopy_generated:
        if _saved_stdout is not None:
            sys.stdout = _saved_stdout
        print(f"\nGenerated copyright written to: {copyright_file}",
              file=sys.stderr)
PYEOF

echo "Done." >&2
