#!/usr/bin/env nix-shell
#!nix-shell -p "python3.withPackages (p: with p; [ ])" -i python3
"""Generate per-file patches between corepkgs and nixpkgs, handling directory structure differences."""

import argparse
import difflib
import os
import re
import shutil
import subprocess
import sys
import tempfile
from collections import defaultdict
from pathlib import Path
from typing import Optional

PATCHES_DIR = "patches"
IGNORED_PATCHES_DIR = "maintainers/scripts/sync-with-nixpkgs/ignored-patches"

# =============================================================================
# PATCH CONTENT TRANSFORMATIONS AND FILTERS
# =============================================================================

# Path transformations to apply to nixpkgs file content before generating diffs.
# These transform nixpkgs paths to match corepkgs conventions.
# Format: (regex_pattern, replacement)
# Order matters - more specific patterns should come first!
PATH_TRANSFORMATIONS = [
    # Specific perl-modules transformations (must come before general one)
    # ../development/perl-modules/generic -> ./buildPerlPackage.nix
    (r"\.\./development/perl-modules/generic", "./buildPerlPackage.nix"),
    # ../development/perl-modules/*.patch -> ./patches/*.patch
    (r"\.\./development/perl-modules/([^/]+\.patch)", r"./patches/\1"),
    (r"\.\./development/perl-modules", "./patches"),
    (r"\.\./os-specific/linux/", "./"),
]

# Pattern name aliases: Map nixpkgs pattern names to corepkgs equivalents.
# These transform nixpkgs pattern names to match corepkgs conventions before diffing.
# Format: (nixpkgs_pattern_name_pattern, corepkgs_pattern_name)
# Use word boundaries (\b) to match whole package names only.
PATTERN_ALIASES = [
    (r"\blibX11\b", "libx11"),
    (r"\bdocbook_xsl\b", "docbook-xsl-nons"),
    (r"\bdocbook_xsl_ns\b", "docbook-xsl-ns"),
    (r"\bnixpkgsArgs\b", "pkgsArgs"),
    (r"\bautoconf269\b", "autoconf.v2_69"),
    (r"\bautoconf271\b", "autoconf.v2_71"),
    (r"\bcmakeMinimal\b", "cmake.minimal"),
]

# Regex patterns for lines that should be filtered out from diffs.
# If a hunk only contains changes matching these patterns, it will be skipped.
# These patterns match the content of the changed lines (without +/- prefix).
IGNORE_CHANGE_PATTERNS = [
    r"^\s*(meta\.)?maintainers\s*=",
    r"^\s*(meta\.)?teams\s*=",
    r"^\s*$",
    r"^\s*# nixpkgs-update: no auto update",
]

# Patterns for corepkgs-specific lines that should be hidden from diffs.
# These are patterns that corepkgs uses but nixpkgs doesn't have.
# Lines matching these patterns will be filtered from BOTH additions and deletions.
COREPKGS_SPECIFIC_PATTERNS = [
    r"cmake\.configurePhaseHook",
    r"meson\.configurePhaseHook",
]

# Patterns that should be filtered ONLY when removing lines (deletions).
# These are patterns that corepkgs removes from nixpkgs and we don't want to show in diffs.
IGNORE_DELETION_PATTERNS = [
]

# Check new files for existing directories in the following directories
CHECK_NEW_FILES = [
    # keep-sorted start
    "build-support",
    "common-updater",
    "pkgs",
    "python",
    "stdenv",
    "systems",
    # keep-sorted end
]

# Should not be checked for new files of folders in the following directories, only for existing
IGNORE_NEW = [
]

# Should not be checked for patches or new files at all
IGNORE_DIRS = [
    # keep-sorted start
    ".ekaci",
    ".github",
    "config",
    "docs",
    "maintainers",
    "patches",  # autogenerated
    "perl",  # too many changes, update manually
    "pkgs-many",
    "pkgs/lndir", # our minimal implementation
    "stdenv/cygwin",
    "stdenv/darwin",
    "stdenv/freebsd",
    "stdenv/linux/bootstrap-files",  # lets wait for minimal-bootstrap
    # keep-sorted end
]

# Should not be checked for patches at all
IGNORE_FILES = [
    # keep-sorted start
    ".gitignore",
    "LICENSE",
    "README.md",
    "default.nix",
    "flake.lock",
    "flake.nix",
    "lib.nix",
    "pins.nix",
    "stdenv/README.md",
    "stdenv/aliases.nix",  # we have our own aliases
    "top-level.nix",
    # keep-sorted end
]

# Patches for these directories should be grouped by folders
FLAT_DIRS = [
    # keep-sorted start
    "build-support",
    "common-updater",
    "os-specific/linux",
    "pkgs",
    "systems",
    # keep-sorted end
]

PATH_MAPPINGS = {
    # keep-sorted start
    "build-support": "pkgs/build-support",
    "common-updater": "pkgs/common-updater",
    "perl/buildperlpackage.nix": "pkgs/development/perl-modules/generic",
    "perl/patches": "pkgs/development/perl-modules",
    "perl/perl-packages.nix": "pkgs/top-level/perl-packages.nix",
    "pkgs": "pkgs/by-name",
    "pkgs/automake": "pkgs/development/tools/misc/automake",
    "pkgs/bash": "pkgs/shells/bash",
    "pkgs/binutils": "pkgs/development/tools/misc/binutils",
    "pkgs/boost": "pkgs/development/libraries/boost",
    "pkgs/dotnet": "pkgs/development/compilers/dotnet",
    "pkgs/gcc": "pkgs/development/compilers/gcc",
    "pkgs/glibc": "pkgs/development/libraries/glibc",
    "pkgs/gobject-introspection": "pkgs/development/libraries/gobject-introspection",
    "pkgs/javaPackages/openjdk": "pkgs/development/compilers/openjdk",
    "pkgs/linux": "pkgs/os-specific/linux",
    "pkgs/linux/default.nix": "pkgs/top-level/linux-kernels.nix",
    "pkgs/linux/kernel/kernel-config.nix": "nixos/modules/system/boot/kernel_config.nix",
    "pkgs/linux/pkgs": "pkgs/os-specific/linux",
    "pkgs/llvm": "pkgs/development/compilers/llvm",
    "pkgs/nix": "pkgs/tools/package-management/nix",
    "pkgs/openssh": "pkgs/tools/networking/openssh",
    "pkgs/perl": "pkgs/development/interpreters/perl",
    "pkgs/rust": "pkgs/development/compilers/rust",
    "pkgs/systemd": "pkgs/os-specific/linux/systemd",
    "pkgs/texlive": "pkgs/tools/typesetting/tex/texlive",
    "pkgs/xorg": "pkgs/servers/x11/xorg",
    "python": "pkgs/development/interpreters/python",
    "python/pkgs": "pkgs/development/python-modules",
    "release.nix": "pkgs/top-level/release.nix",
    "stdenv": "pkgs/stdenv",
    "stdenv/config.nix": "pkgs/top-level/config.nix",
    "stdenv/impure.nix": "pkgs/top-level/impure.nix",
    "stdenv/pure.nix": "pkgs/top-level/default.nix",
    "stdenv/release/lib.nix": "pkgs/top-level/release-lib.nix",
    "stdenv/splice.nix": "pkgs/top-level/splice.nix",
    "stdenv/stage.nix": "pkgs/top-level/stage.nix",
    "stdenv/variants.nix": "pkgs/top-level/variants.nix",
    "systems": "lib/systems",
    "unixtools.nix": "pkgs/top-level/unixtools.nix",
    # keep-sorted end
}


def has_path_mapping(corepkgs_path: str) -> bool:
    """
    Check if a corepkgs path has a mapping in PATH_MAPPINGS.

    Returns True if a mapping exists, False otherwise.
    """
    path_str = str(Path(corepkgs_path))

    for corepkgs_prefix, _ in PATH_MAPPINGS.items():
        if path_str == corepkgs_prefix or path_str.startswith(corepkgs_prefix + "/"):
            return True

    return False


def resolve_nixpkgs_path(corepkgs_path: str, nixpkgs_root: Path) -> Optional[Path]:
    """
    Map a corepkgs relative path to its corresponding nixpkgs path.

    Uses PATH_MAPPINGS with longest-prefix matching. For pkgs/by-name targets,
    handles the default.nix -> package.nix rename and 2-char prefix extraction.

    Returns None if no mapping exists or the mapped file doesn't exist.
    """
    path = Path(corepkgs_path)
    path_str = str(path)

    # Find the longest matching prefix in PATH_MAPPINGS
    best_match = None
    best_match_len = 0

    for corepkgs_prefix, nixpkgs_prefix in PATH_MAPPINGS.items():
        if path_str == corepkgs_prefix or path_str.startswith(corepkgs_prefix + "/"):
            if len(corepkgs_prefix) > best_match_len:
                best_match = (corepkgs_prefix, nixpkgs_prefix)
                best_match_len = len(corepkgs_prefix)

    if best_match is None:
        return None

    corepkgs_prefix, nixpkgs_prefix = best_match

    # Calculate the remaining path after the matched prefix
    if path_str == corepkgs_prefix:
        remainder = ""
    else:
        remainder = path_str[len(corepkgs_prefix) + 1 :]  # +1 for the "/"

    # Special handling for pkgs/by-name mapping
    if nixpkgs_prefix == "pkgs/by-name" and remainder:
        parts = Path(remainder).parts
        if len(parts) >= 1:
            pkg_name = parts[0]
            # Extract 2-char prefix for by-name structure
            if len(pkg_name) >= 2:
                prefix = pkg_name[:2].lower()
                rest_parts = parts[1:] if len(parts) > 1 else ("default.nix",)

                # Handle default.nix -> package.nix rename
                if rest_parts and rest_parts[-1] == "default.nix":
                    rest_parts = rest_parts[:-1] + ("package.nix",)

                nixpkgs_path = (
                    nixpkgs_root
                    / nixpkgs_prefix
                    / prefix
                    / pkg_name
                    / Path(*rest_parts)
                )
                return nixpkgs_path if nixpkgs_path.exists() else None

    # Standard mapping: just append remainder to nixpkgs prefix
    if remainder:
        nixpkgs_path = nixpkgs_root / nixpkgs_prefix / remainder
    else:
        nixpkgs_path = nixpkgs_root / nixpkgs_prefix

    return nixpkgs_path if nixpkgs_path.exists() else None


def should_skip_path(path: str) -> bool:
    """Check if a path should be skipped based on IGNORE_DIRS and IGNORE_FILES."""
    # Check IGNORE_FILES - exact path match only
    # e.g., "default.nix" matches only root default.nix, not "pkgs/curl/default.nix"
    # e.g., "stdenv/aliases.nix" matches that specific path
    if path in IGNORE_FILES:
        return True

    # Check IGNORE_DIRS
    for ignore_dir in IGNORE_DIRS:
        if path == ignore_dir or path.startswith(ignore_dir + "/"):
            return True

    return False


def should_check_new_files(path: str) -> bool:
    """Check if a directory should be checked for new files from nixpkgs."""
    # Check if path is in IGNORE_NEW
    for ignore_path in IGNORE_NEW:
        if path == ignore_path or path.startswith(ignore_path + "/"):
            return False

    # Check if path is in CHECK_NEW_FILES
    for check_path in CHECK_NEW_FILES:
        if path == check_path or path.startswith(check_path + "/"):
            return True

    return False


def is_flat_dir(path: str) -> bool:
    """Check if a path is within a FLAT_DIRS directory."""
    for flat_dir in FLAT_DIRS:
        if path == flat_dir or path.startswith(flat_dir + "/"):
            return True
    return False


def get_flat_dir_subfolder(path: str) -> Optional[tuple[str, str]]:
    """
    For a path in a FLAT_DIR, get the (flat_dir, subfolder) tuple.

    Example: "pkgs/curl/default.nix" -> ("pkgs", "curl")
             "build-support/fetchgit/builder.sh" -> ("build-support", "fetchgit")
    """
    for flat_dir in FLAT_DIRS:
        if path.startswith(flat_dir + "/"):
            remainder = path[len(flat_dir) + 1 :]
            parts = Path(remainder).parts
            if parts:
                return (flat_dir, parts[0])
    return None


def get_patch_output_path(corepkgs_path: str) -> str:
    """
    Determine the output patch path for a given corepkgs file.

    For FLAT_DIRS: patches/<flat_dir>/<subfolder>.patch
    For others: patches/<path>.patch (preserving directory structure)
    """
    subfolder_info = get_flat_dir_subfolder(corepkgs_path)
    if subfolder_info:
        flat_dir, subfolder = subfolder_info
        return f"{PATCHES_DIR}/{flat_dir}/{subfolder}.patch"
    else:
        return f"{PATCHES_DIR}/{corepkgs_path}.patch"


def apply_path_transformations(content: str) -> str:
    """
    Apply path transformations to nixpkgs content before diffing.

    This transforms nixpkgs-style paths to corepkgs-style paths so that
    only meaningful differences are shown in the patch.
    """
    result = content
    for pattern, replacement in PATH_TRANSFORMATIONS:
        result = re.sub(pattern, replacement, result)
    return result


def apply_pattern_aliases(nixpkgs_content: str, corepkgs_content: str) -> str:
    """
    Apply pattern name alias transformations to nixpkgs content, but only if
    the corepkgs file actually uses the aliased pattern.

    This prevents unnecessary transformations when both files use the same pattern.

    Args:
        nixpkgs_content: Content from nixpkgs file
        corepkgs_content: Content from corepkgs file

    Returns:
        Transformed nixpkgs content with aliases applied where needed
    """
    result = nixpkgs_content

    for nixpkgs_pattern, corepkgs_pattern in PATTERN_ALIASES:
        # Check if corepkgs actually uses the corepkgs pattern
        if re.search(r"\b" + re.escape(corepkgs_pattern) + r"\b", corepkgs_content):
            # Check if nixpkgs uses the different pattern
            if re.search(nixpkgs_pattern, nixpkgs_content):
                # Only apply transformation if both conditions are met
                result = re.sub(nixpkgs_pattern, corepkgs_pattern, result)

    return result


def load_ignored_patch(patch_path: str, ignored_patches_dir: Path) -> Optional[str]:
    """
    Load an ignored patch file if it exists.

    Args:
        patch_path: The relative patch path (e.g., "patches/pkgs/curl.patch")
        ignored_patches_dir: Directory containing ignored patches

    Returns:
        Patch content if exists, None otherwise
    """
    # Extract the patch filename from the full path
    # e.g., "patches/pkgs/curl.patch" -> "pkgs/curl.patch"
    if patch_path.startswith(f"{PATCHES_DIR}/"):
        rel_patch_path = patch_path[len(f"{PATCHES_DIR}/") :]
    else:
        rel_patch_path = patch_path

    ignored_patch_file = ignored_patches_dir / rel_patch_path

    if ignored_patch_file.exists() and ignored_patch_file.is_file():
        try:
            with open(ignored_patch_file, "r", encoding="utf-8") as f:
                return f.read()
        except (IOError, OSError):
            return None

    return None


def apply_patch_to_content(
    content: str, patch_content: str, file_path: str
) -> Optional[str]:
    """
    Apply a patch to file content using patch command.

    Args:
        content: Original file content
        patch_content: Unified diff patch content
        file_path: Relative file path that the patch expects

    Returns:
        Patched content if successful, None if patch fails
    """
    with tempfile.TemporaryDirectory() as tmpdir:
        tmp_path = Path(tmpdir)

        # Create the directory structure for the file
        full_file_path = tmp_path / file_path
        full_file_path.parent.mkdir(parents=True, exist_ok=True)

        # Write original content to temp file
        full_file_path.write_text(content, encoding="utf-8")

        # Apply patch using patch command
        # Use -p1 to strip "a/" and "b/" prefixes
        # Use --no-backup-if-mismatch to avoid creating .orig files
        result = subprocess.run(
            ["patch", "-p1", "--no-backup-if-mismatch", file_path],
            input=patch_content,
            cwd=tmp_path,
            capture_output=True,
            text=True,
        )

        if result.returncode != 0:
            return None

        # Read patched content
        try:
            return full_file_path.read_text(encoding="utf-8")
        except (IOError, OSError):
            return None


def should_ignore_change(line: str) -> bool:
    """
    Check if a changed line should be ignored based on IGNORE_CHANGE_PATTERNS.

    The line should be the content without the +/- prefix.
    """
    for pattern in IGNORE_CHANGE_PATTERNS:
        if re.search(pattern, line):
            return True
    return False


def should_ignore_deletion(line: str) -> bool:
    """
    Check if a deleted line should be ignored based on IGNORE_DELETION_PATTERNS.

    This is used specifically for filtering deletions (lines starting with -).
    The line should be the content without the - prefix.
    """
    for pattern in IGNORE_DELETION_PATTERNS:
        if re.search(pattern, line):
            return True
    return False


def is_corepkgs_specific_line(line: str) -> bool:
    """
    Check if a line contains corepkgs-specific content.

    These are patterns that corepkgs uses but nixpkgs doesn't have.
    Lines matching these patterns will be hidden from diffs (both additions and deletions).
    """
    for pattern in COREPKGS_SPECIFIC_PATTERNS:
        if re.search(pattern, line):
            return True
    return False


def filter_diff_lines(diff_lines: list[str]) -> list[str]:
    """
    Filter diff to remove unwanted changes and recalculate hunk headers.

    - Removes lines matching IGNORE_CHANGE_PATTERNS (maintainers/teams/aliases)
    - Removes lines matching IGNORE_DELETION_PATTERNS (teams = deletions)
    - Removes lines matching COREPKGS_SPECIFIC_PATTERNS (cmake.configurePhaseHook)
    - Recalculates hunk headers to reflect filtered content

    Returns filtered diff lines, or empty list if no meaningful changes remain.
    """
    if not diff_lines:
        return []

    import re

    # Parse hunks
    file_headers = []
    hunks = []
    current_hunk_header = None
    current_hunk_lines = []

    for line in diff_lines:
        if line.startswith("---") or line.startswith("+++"):
            file_headers.append(line)
        elif line.startswith("@@"):
            if current_hunk_header is not None:
                hunks.append((current_hunk_header, current_hunk_lines))
            current_hunk_header = line
            current_hunk_lines = []
        elif current_hunk_header is not None:
            current_hunk_lines.append(line)

    if current_hunk_header is not None:
        hunks.append((current_hunk_header, current_hunk_lines))

    # Filter and rebuild hunks
    result = file_headers[:]
    # Track how many lines we've added/removed due to filtering
    # This affects where subsequent hunks will be in the actual files
    cumulative_old_offset = 0
    cumulative_new_offset = 0

    for header, lines in hunks:
        # Parse header - handle both @@ -X,Y +A,B @@ and @@ -X +A,B @@ formats
        match = re.match(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$", header)
        if not match:
            continue

        old_start = int(match.group(1))
        old_count = int(match.group(2)) if match.group(2) else 1
        new_start = int(match.group(3))
        new_count = int(match.group(4)) if match.group(4) else 1
        suffix = match.group(5)

        filtered_lines = []
        filtered_old_lines = 0
        filtered_new_lines = 0
        in_maintainers_or_teams = False
        block_prefix = ""

        for line in lines:
            if line.startswith("+"):
                content = line[1:]

                if re.match(r"^\s*(meta\.)?maintainers\s*=", content) or re.match(
                    r"^\s*(meta\.)?teams\s*=", content
                ):
                    in_maintainers_or_teams = True
                    block_prefix = "+"
                    if re.search(r"\]\s*;?\s*$", content):
                        in_maintainers_or_teams = False
                    filtered_new_lines += 1
                    continue

                if in_maintainers_or_teams and block_prefix == "+":
                    filtered_new_lines += 1
                    if re.match(r"^\s*\]\s*;?\s*$", content):
                        in_maintainers_or_teams = False
                    continue

                if should_ignore_change(content) or is_corepkgs_specific_line(content):
                    filtered_new_lines += 1
                    continue
                filtered_lines.append(line)

            elif line.startswith("-"):
                content = line[1:]

                if re.match(r"^\s*(meta\.)?maintainers\s*=", content) or re.match(
                    r"^\s*(meta\.)?teams\s*=", content
                ):
                    in_maintainers_or_teams = True
                    block_prefix = "-"
                    if re.search(r"\]\s*;?\s*$", content):
                        in_maintainers_or_teams = False
                    filtered_old_lines += 1
                    continue

                if in_maintainers_or_teams and block_prefix == "-":
                    filtered_old_lines += 1
                    if re.match(r"^\s*\]\s*;?\s*$", content):
                        in_maintainers_or_teams = False
                    continue

                if (
                    should_ignore_change(content)
                    or should_ignore_deletion(content)
                    or is_corepkgs_specific_line(content)
                ):
                    filtered_old_lines += 1
                    continue
                filtered_lines.append(line)
            else:
                in_maintainers_or_teams = False
                if line.strip() == "":
                    filtered_lines.append("")
                else:
                    filtered_lines.append(line)

        # Check if hunk still has changes
        has_changes = any(l.startswith(("+", "-")) for l in filtered_lines)
        if not has_changes:
            # This hunk was completely filtered out
            # Adjust offsets for next hunk
            cumulative_old_offset -= filtered_old_lines
            cumulative_new_offset -= filtered_new_lines
            continue

        # Recalculate line counts based on filtered lines
        new_old_count = sum(1 for l in filtered_lines if not l.startswith("+"))
        new_new_count = sum(1 for l in filtered_lines if not l.startswith("-"))

        # Adjust start positions based on previous filtering
        adjusted_old_start = old_start + cumulative_old_offset
        adjusted_new_start = new_start + cumulative_new_offset

        # Update cumulative offsets for next hunk
        # The offset changes by how many lines we filtered out
        cumulative_old_offset -= filtered_old_lines
        cumulative_new_offset -= filtered_new_lines

        # Build new header
        new_header = f"@@ -{adjusted_old_start},{new_old_count} +{adjusted_new_start},{new_new_count} @@{suffix}"
        result.append(new_header)
        result.extend(filtered_lines)

    # If only headers remain, return empty
    if len(result) == len(file_headers):
        return []

    return result


def generate_patch(
    corepkgs_file: Path,
    nixpkgs_file: Path,
    corepkgs_rel_path: str,
    nixpkgs_rel_path: str,
) -> Optional[str]:
    """
    Generate a unified diff from corepkgs to nixpkgs.

    The patches show what changes from nixpkgs should be applied to corepkgs
    to sync with upstream. When applied to corepkgs files, they bring in nixpkgs changes.

    Direction: corepkgs (old) -> nixpkgs (new)
    - Lines starting with `-` are removed from corepkgs
    - Lines starting with `+` are added to corepkgs (from nixpkgs)

    The patch uses corepkgs paths in headers so it can be applied with -p1 from
    the corepkgs root directory.

    Applies path transformations and filters out unwanted changes.

    Returns None if files are identical or if there's an error reading files.
    """
    try:
        with open(nixpkgs_file, "r", encoding="utf-8", errors="replace") as f:
            nixpkgs_content = f.read()
    except (IOError, OSError):
        return None

    try:
        with open(corepkgs_file, "r", encoding="utf-8", errors="replace") as f:
            corepkgs_content = f.read()
    except (IOError, OSError):
        return None

    # Apply path transformations to nixpkgs content
    nixpkgs_transformed = apply_path_transformations(nixpkgs_content)

    # Apply pattern name alias transformations (only where corepkgs actually differs)
    nixpkgs_transformed = apply_pattern_aliases(nixpkgs_transformed, corepkgs_content)

    # Split into lines for difflib (without keeping line endings)
    nixpkgs_lines = nixpkgs_transformed.splitlines(keepends=False)
    corepkgs_lines = corepkgs_content.splitlines(keepends=False)

    # Generate unified diff FROM corepkgs TO nixpkgs
    # This shows what changes from nixpkgs should be applied to corepkgs
    # When applied to corepkgs, it brings in nixpkgs changes
    # Use corepkgs path for both sides so patch can be applied with -p1 from corepkgs root
    diff = list(
        difflib.unified_diff(
            corepkgs_lines,
            nixpkgs_lines,
            fromfile=f"a/{corepkgs_rel_path}",
            tofile=f"b/{corepkgs_rel_path}",
            lineterm="",
        )
    )

    if not diff:
        return None  # Files are identical

    # Filter out unwanted changes
    filtered_diff = filter_diff_lines(diff)

    if not filtered_diff:
        return None  # All changes were filtered out

    # Join with newlines
    result = "\n".join(filtered_diff)
    if result and not result.endswith("\n"):
        result += "\n"

    return result


def collect_corepkgs_files(corepkgs_root: Path) -> list[str]:
    """
    Walk the corepkgs directory and collect all files that should be processed.

    Respects IGNORE_DIRS and IGNORE_FILES.
    """
    files = []

    for root, dirs, filenames in os.walk(corepkgs_root):
        rel_root = os.path.relpath(root, corepkgs_root)
        if rel_root == ".":
            rel_root = ""

        # Filter out ignored directories
        dirs[:] = [
            d
            for d in dirs
            if not should_skip_path(os.path.join(rel_root, d) if rel_root else d)
            and not d.startswith(".")
        ]

        for filename in filenames:
            if filename.startswith("."):
                continue

            if rel_root:
                rel_path = os.path.join(rel_root, filename)
            else:
                rel_path = filename

            if not should_skip_path(rel_path):
                files.append(rel_path)

    return sorted(files)


def group_files_by_patch(files: list[str]) -> dict[str, list[str]]:
    """
    Group files by their target patch file.

    For FLAT_DIRS, multiple files go into one patch per subfolder.
    For others, each file gets its own patch.
    """
    groups: dict[str, list[str]] = defaultdict(list)

    for file_path in files:
        patch_path = get_patch_output_path(file_path)
        groups[patch_path].append(file_path)

    return dict(groups)


def generate_combined_patch(
    file_pairs: list[tuple[str, Path, Path, str]],
    patch_path: Optional[str] = None,
    ignored_patches_dir: Optional[Path] = None,
) -> Optional[str]:
    """
    Generate a combined patch for multiple file pairs.

    If an ignored patch exists for this patch_path, it will be applied to corepkgs
    content first, then generate the diff against nixpkgs. This shows the delta
    between the ignored patch and current nixpkgs state.

    Each tuple contains: (corepkgs_rel_path, corepkgs_file, nixpkgs_file, nixpkgs_rel_path)
    """
    ignored_patch = None
    if ignored_patches_dir and patch_path:
        ignored_patch = load_ignored_patch(patch_path, ignored_patches_dir)

    patches = []

    for corepkgs_rel_path, corepkgs_file, nixpkgs_file, nixpkgs_rel_path in file_pairs:
        # Read original corepkgs content
        try:
            with open(corepkgs_file, "r", encoding="utf-8", errors="replace") as f:
                corepkgs_content = f.read()
        except (IOError, OSError):
            continue

        # If there's an ignored patch, apply it to corepkgs content first
        if ignored_patch:
            patched_content = apply_patch_to_content(
                corepkgs_content, ignored_patch, corepkgs_rel_path
            )
            if patched_content is not None:
                # Write patched content to temp file and use that as corepkgs file
                with tempfile.NamedTemporaryFile(
                    mode="w", encoding="utf-8", delete=False, suffix=".nix"
                ) as tmp:
                    tmp.write(patched_content)
                    tmp_path = Path(tmp.name)

                try:
                    patch = generate_patch(
                        tmp_path, nixpkgs_file, corepkgs_rel_path, nixpkgs_rel_path
                    )
                    if patch:
                        patches.append(patch)
                finally:
                    tmp_path.unlink()
            else:
                # Patch failed to apply, fall back to regular diff
                patch = generate_patch(
                    corepkgs_file, nixpkgs_file, corepkgs_rel_path, nixpkgs_rel_path
                )
                if patch:
                    patches.append(patch)
        else:
            # No ignored patch, generate regular diff
            patch = generate_patch(
                corepkgs_file, nixpkgs_file, corepkgs_rel_path, nixpkgs_rel_path
            )
            if patch:
                patches.append(patch)

    if not patches:
        return None

    return "\n".join(patches)


def main():
    """Main entry point for the sync script."""
    parser = argparse.ArgumentParser(
        description="Generate patches from nixpkgs to corepkgs"
    )
    parser.add_argument(
        "--corepkgs",
        type=Path,
        default=Path("."),
        help="Path to corepkgs root (default: current directory)",
    )
    parser.add_argument(
        "--nixpkgs",
        type=Path,
        default=Path("../nixpkgs"),
        help="Path to nixpkgs root (default: ../nixpkgs)",
    )
    parser.add_argument(
        "--output",
        type=Path,
        default=None,
        help=f"Output directory for patches (default: <corepkgs>/{PATCHES_DIR})",
    )
    parser.add_argument(
        "--ignored-patches",
        type=Path,
        default=None,
        help=f"Directory containing patches to ignore (default: <corepkgs>/{IGNORED_PATCHES_DIR})",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Don't write patches, just report what would be done",
    )
    parser.add_argument(
        "--verbose",
        "-v",
        action="store_true",
        help="Print verbose output",
    )

    args = parser.parse_args()

    corepkgs_root = args.corepkgs.resolve()
    nixpkgs_root = args.nixpkgs.resolve()
    output_dir = args.output.resolve() if args.output else corepkgs_root / PATCHES_DIR
    ignored_patches_dir = (
        args.ignored_patches.resolve()
        if args.ignored_patches
        else corepkgs_root / IGNORED_PATCHES_DIR
    )

    # Delete patches directory before generating new patches
    if output_dir.exists():
        if args.dry_run:
            print(f"Would delete: {output_dir}")
        else:
            if args.verbose:
                print(f"Deleting existing patches directory: {output_dir}")
            shutil.rmtree(output_dir)

    if not corepkgs_root.exists():
        print(f"Error: corepkgs directory not found: {corepkgs_root}", file=sys.stderr)
        sys.exit(1)

    if not nixpkgs_root.exists():
        print(f"Error: nixpkgs directory not found: {nixpkgs_root}", file=sys.stderr)
        sys.exit(1)

    if args.verbose:
        print(f"Corepkgs root: {corepkgs_root}")
        print(f"Nixpkgs root: {nixpkgs_root}")
        print(f"Output directory: {output_dir}")

    # Collect all files from corepkgs
    print("Collecting corepkgs files...")
    corepkgs_files = collect_corepkgs_files(corepkgs_root)
    print(f"Found {len(corepkgs_files)} files in corepkgs")

    # Group files by patch
    file_groups = group_files_by_patch(corepkgs_files)
    print(f"Grouped into {len(file_groups)} patch targets")

    # Track missing files and statistics
    missing_files: list[str] = []
    unmatched_mappings: list[str] = []
    patches_generated = 0
    patches_skipped = 0

    # Process each patch group
    for patch_path, files in sorted(file_groups.items()):
        file_pairs: list[tuple[str, Path, Path, str]] = []

        for corepkgs_rel_path in files:
            corepkgs_file = corepkgs_root / corepkgs_rel_path

            # Check if path has a mapping first
            if not has_path_mapping(corepkgs_rel_path):
                unmatched_mappings.append(corepkgs_rel_path)
                if args.verbose:
                    print(f"  No path mapping: {corepkgs_rel_path}")
                continue

            nixpkgs_path = resolve_nixpkgs_path(corepkgs_rel_path, nixpkgs_root)

            if nixpkgs_path is None:
                missing_files.append(corepkgs_rel_path)
                if args.verbose:
                    print(f"  Missing in nixpkgs: {corepkgs_rel_path}")
                continue

            nixpkgs_rel_path = str(nixpkgs_path.relative_to(nixpkgs_root))
            file_pairs.append(
                (corepkgs_rel_path, corepkgs_file, nixpkgs_path, nixpkgs_rel_path)
            )

        if not file_pairs:
            continue

        # Generate combined patch
        patch_content = generate_combined_patch(
            file_pairs, patch_path, ignored_patches_dir
        )

        if patch_content is None:
            patches_skipped += 1
            if args.verbose:
                print(f"  Skipped (identical): {patch_path}")
            continue

        patches_generated += 1

        if args.dry_run:
            print(f"Would write: {patch_path} ({len(file_pairs)} files)")
        else:
            # Write patch file
            full_patch_path = output_dir / patch_path.replace(f"{PATCHES_DIR}/", "", 1)
            full_patch_path.parent.mkdir(parents=True, exist_ok=True)

            with open(full_patch_path, "w", encoding="utf-8") as f:
                f.write(patch_content)

            if args.verbose:
                print(f"  Written: {full_patch_path}")

    # Write missing files report
    missing_report_path = output_dir / "missing-in-nixpkgs.txt"
    if missing_files:
        if args.dry_run:
            print(f"Would write missing files report: {missing_report_path}")
            print(f"  {len(missing_files)} files missing in nixpkgs")
        else:
            missing_report_path.parent.mkdir(parents=True, exist_ok=True)
            with open(missing_report_path, "w", encoding="utf-8") as f:
                f.write(
                    "# Files in corepkgs that have no corresponding file in nixpkgs\n"
                )
                f.write(f"# Generated by sync-with-nixpkgs script\n\n")
                for file_path in sorted(missing_files):
                    f.write(f"{file_path}\n")
            print(f"Missing files report written to: {missing_report_path}")

    # Write unmatched mappings report
    unmatched_report_path = output_dir / "unmatched-path-mappings.txt"
    if unmatched_mappings:
        if args.dry_run:
            print(f"Would write unmatched mappings report: {unmatched_report_path}")
            print(f"  {len(unmatched_mappings)} files without path mappings")
        else:
            unmatched_report_path.parent.mkdir(parents=True, exist_ok=True)
            with open(unmatched_report_path, "w", encoding="utf-8") as f:
                f.write("# Files in corepkgs that have no PATH_MAPPINGS entry\n")
                f.write(
                    "# These files need a mapping added to PATH_MAPPINGS in script.py\n"
                )
                f.write(f"# Generated by sync-with-nixpkgs script\n\n")
                for file_path in sorted(unmatched_mappings):
                    f.write(f"{file_path}\n")
            print(f"Unmatched mappings report written to: {unmatched_report_path}")

    # Summary
    print(f"\nSummary:")
    print(f"  Patches generated: {patches_generated}")
    print(f"  Patches skipped (identical): {patches_skipped}")
    print(f"  Files missing in nixpkgs: {len(missing_files)}")
    print(f"  Files without path mappings: {len(unmatched_mappings)}")


if __name__ == "__main__":
    main()
