#!/usr/bin/python3
# -*- coding: utf-8 -*-

from __future__ import annotations

import difflib
import re
import shutil
import subprocess
import tarfile
from pathlib import Path
from typing import Callable

import urllib3  # pylint: disable=import-error
from urllib3.exceptions import HTTPError  # pylint: disable=import-error

from sbo_create.handlers.fallback import SboResolver
from sbo_create.handlers.files import FileHandler
from sbo_create.handlers.validate import ValidateHandler
from sbo_create.models import (EMAIL_RE, PROG, SOURCE_EXTENSIONS, AppContext,
                               colorize, confirm)


def _print_tree_branch(  # pylint: disable=too-many-arguments,too-many-positional-arguments
    pkg: str,
    prefix: str,
    is_last: bool,
    seen: set[str],
    collected: set[str],
    get_children: Callable[[str], list[str]],
) -> None:
    """Recursively print one branch of a dependency tree.

    Args:
        pkg: Package name to print at this node.
        prefix: Indentation string accumulated from parent calls.
        is_last: Whether this node is the last child of its parent.
        seen: Set of already-expanded packages; prevents infinite loops on cycles.
        collected: Mutable set that accumulates every visited package name.
        get_children: Callable returning the direct children of a given package.
    """
    connector: str = '└─' if is_last else '├─'
    print(f"{prefix}{connector} {colorize(pkg, 'cyan')}")
    collected.add(pkg)
    if pkg in seen:
        return
    seen.add(pkg)
    children: list[str] = get_children(pkg)
    child_prefix: str = prefix + ('   ' if is_last else '│  ')
    for i, child in enumerate(children):
        _print_tree_branch(child, child_prefix, i == len(children) - 1,
                           seen, collected, get_children)


class MiscHandler:
    """Handle package management, search, configuration and repository commands.

    Provides: maintainer configuration, template updates, package creation,
    source cleanup, info display, package search and existence checks,
    file editing, diff, rename and dependency tree.
    """

    def __init__(self, ctx: AppContext, files: FileHandler,
                 validate: ValidateHandler, resolver: SboResolver) -> None:
        self.ctx = ctx
        self.files = files
        self.validate = validate
        self.resolver = resolver

    def _require_repo_path(self) -> Path:
        """Return the resolved SBo repo path, or exit with a hard error if unconfigured.

        Returns:
            Absolute resolved Path to the local SBo repository.
        """
        repo_path_str: str = self.ctx.maintainer.sbo_repo_path
        if not repo_path_str:
            print(colorize(
                f"{PROG}: SBo repo path not configured. Run '{PROG} maintainer'.", 'red'))
            raise SystemExit(1)
        repo_path: Path = Path(repo_path_str).expanduser().resolve()
        if not repo_path.is_dir():
            print(colorize(f"{PROG}: SBo repo path not found: {repo_path}", 'red'))
            raise SystemExit(1)
        return repo_path

    def configure_maintainer(self, show: bool = False) -> None:
        """Interactively create or update the maintainer configuration file.

        Prompts for each maintainer field (name, email, location, editor,
        SBo version, git settings). Press Enter to keep the current value,
        or type '!' to clear a field. If show is True, only displays the
        current values without prompting for changes.

        Args:
            show: If True, print current configuration and exit without prompting.
        """
        self.files.read_maintainer_file()

        fields: list[tuple[str, str, str]] = [
            ('NAME', 'name', self.ctx.maintainer.name),
            ('EMAIL', 'email', self.ctx.maintainer.email),
            ('LOCATION', 'where_you_live', self.ctx.maintainer.where_you_live),
            ('EDITOR', 'editor', self.ctx.maintainer.editor),
            ('EDITOR_OPTIONS', 'editor_options', self.ctx.maintainer.editor_options),
            ('SBO_VERSION', 'sbo_repo_version', self.ctx.maintainer.sbo_repo_version),
            ('SBO_REPO_PATH', 'sbo_repo_path', self.ctx.maintainer.sbo_repo_path),
            ('GIT_BRANCH', 'git_branch', self.ctx.maintainer.git_branch),
            ('GIT_MAIN_BRANCH', 'git_main_branch', self.ctx.maintainer.git_main_branch),
            ('GIT_COMMIT_CMD', 'git_commit_cmd', self.ctx.maintainer.git_commit_cmd),
            ('GIT_PUSH_CMD', 'git_push_cmd', self.ctx.maintainer.git_push_cmd),
        ]

        if show:
            print("Current maintainer configuration:\n")
            for key, _, current in fields:
                print(f"  {colorize(f'{key:<20}', 'cyan')} {colorize(current, 'yellow') if current else ''}")
            print()
            raise SystemExit(0)

        print("Maintainer configuration (press Enter to keep current value, '!' to clear):\n")
        for key, attr, current in fields:
            display: str = colorize(current, 'yellow') if current else ''
            raw: str = input(f"  {colorize(key, 'cyan')} [{display}]: ").strip()
            if raw == '!':
                setattr(self.ctx.maintainer, attr, '')
            elif raw:
                setattr(self.ctx.maintainer, attr, raw)

        config_lines: list[str] = [
            f'NAME="{self.ctx.maintainer.name}"',
            f'EMAIL="{self.ctx.maintainer.email}"',
            f'LOCATION="{self.ctx.maintainer.where_you_live}"',
            f'EDITOR="{self.ctx.maintainer.editor}"',
            f'EDITOR_OPTIONS="{self.ctx.maintainer.editor_options}"',
            f'SBO_VERSION="{self.ctx.maintainer.sbo_repo_version}"',
            f'SBO_REPO_PATH="{self.ctx.maintainer.sbo_repo_path}"',
            f'GIT_BRANCH="{self.ctx.maintainer.git_branch}"',
            f'GIT_MAIN_BRANCH="{self.ctx.maintainer.git_main_branch}"',
            f'GIT_COMMIT_CMD="{self.ctx.maintainer.git_commit_cmd}"',
            f'GIT_PUSH_CMD="{self.ctx.maintainer.git_push_cmd}"',
        ]

        email: str = self.ctx.maintainer.email
        if email and not EMAIL_RE.match(email):
            print(colorize(f"\n{PROG}: Warning: email '{email}' may not be valid.", 'yellow'))

        self.ctx.config_path.mkdir(parents=True, exist_ok=True)
        config_file: Path = self.ctx.config_path / self.ctx.config_file
        config_file.write_text('\n'.join(config_lines) + '\n', encoding='utf-8')
        print(colorize(f"\nConfiguration saved to {config_file}", 'green'))
        raise SystemExit(0)

    def update_templates(self) -> None:
        """Download and update SlackBuild templates and SLACKBUILDS.TXT from slackbuilds.org.

        Fetches all template files (autotools, cmake, perl, python, rubygem, haskell,
        meson, doinst.sh, douninst.sh, slack-desc, template.info, README) into the
        user's templates directory. A header comment is prepended to doinst.sh and
        douninst.sh. Also downloads SLACKBUILDS.TXT for the configured SBo version
        into the local repo cache directory.
        """
        base_url: str = 'https://slackbuilds.org/templates/'
        template_files: list[str] = [
            'autotools-template.SlackBuild',
            'cmake-template.SlackBuild',
            'perl-template.SlackBuild',
            'python-template.SlackBuild',
            'rubygem-template.SlackBuild',
            'haskell-template.SlackBuild',
            'meson-template.SlackBuild',
            'doinst.sh',
            'douninst.sh',
            'slack-desc',
            'template.info',
            'README',
        ]

        doinst_header: str = (
            '# ===========================================================\n'
            '# COPY WHAT YOU WANT, THEN CLOSE THE EDITOR TO CONTINUE\n'
            '# ===========================================================\n'
        )
        header_files: list[str] = ['doinst.sh', 'douninst.sh']

        self.ctx.templates_path.mkdir(parents=True, exist_ok=True)
        http = urllib3.PoolManager()
        print(f"Updating templates in {self.ctx.templates_path}\n")

        for filename in template_files:
            url: str = f'{base_url}{filename}'
            dest: Path = self.ctx.templates_path / filename
            try:
                response = http.request('GET', url)
                if response.status == 200:
                    if filename in header_files:
                        dest.write_text(
                            doinst_header + response.data.decode('utf-8'),
                            encoding='utf-8')
                    else:
                        dest.write_bytes(response.data)
                    print(f"  {colorize('OK', 'green')}  {filename}")
                else:
                    print(f"  {colorize('FAIL', 'red')}  {filename} (HTTP {response.status})")
            except HTTPError as err:
                print(f"  {colorize('FAIL', 'red')}  {filename} ({err})")

        print()

        self.ctx.repo_path.mkdir(parents=True, exist_ok=True)
        repo_url: str = (f'https://slackbuilds.org/slackbuilds/'
                         f'{self.ctx.maintainer.sbo_repo_version}/SLACKBUILDS.TXT')

        print(f"Updating SBo package list in {self.ctx.repo_path}")
        try:
            response = http.request('GET', repo_url)
            if response.status == 200:
                content: str = response.data.decode('utf-8', errors='replace')
                self.files.build_json_cache(content)
                print(f"  {colorize('OK', 'green')}  slackbuilds.json")
            else:
                print(f"  {colorize('FAIL', 'red')}  slackbuilds.json (HTTP {response.status})")
        except HTTPError as err:
            print(f"  {colorize('FAIL', 'red')}  slackbuilds.json ({err})")

        print()
        raise SystemExit(0)

    def list_templates(self) -> None:
        """Print the names of all available SlackBuild templates and exit.

        Scans the user's templates directory for files matching the pattern
        '*-template.SlackBuild' and prints the template type name (the part
        before '-template.SlackBuild'), sorted alphabetically.
        """
        templates: list[str] = sorted(
            f.name.replace('-template.SlackBuild', '')
            for f in self.ctx.templates_path.glob('*-template.SlackBuild')
        )
        print("Available templates:\n")
        for t in templates:
            print(f"  {t}")
        print()
        raise SystemExit(0)

    def _remove_tarballs(self) -> None:
        """Silently remove all source tarballs from the current working directory."""
        for f in self.ctx.current_folder.iterdir():
            if f.is_file() and f.name.endswith(SOURCE_EXTENSIONS):
                f.unlink()

    def create_package(self) -> None:
        """Create the SBo submission tarball (<prgnam>.tar.gz).

        Optionally runs strict validation before packaging. If validation reveals
        that source files are included, offers to clean them first and re-validates.
        Packages all files in current_folder except source archives (matched by
        SOURCE_EXTENSIONS) into a gzip-compressed tar archive placed in the parent
        of current_folder.
        """
        self.files.read_info_file()

        if confirm("Run strict validation before packaging?",
                   before_newline=False, after_newline=False):
            errors, _ = self.validate.collect_validation_errors(
                show_warnings=False, check_sources=True, strict=True)
            if errors:
                print(colorize("\nValidation errors before packaging:", 'red') + '\n')
                for err in errors:
                    print(f"  - {err}")
                has_sources_error: bool = any('must not be included' in e for e in errors)
                if has_sources_error:
                    print()
                    if not self.clean_sources():
                        raise SystemExit(1)
                    errors, _ = self.validate.collect_validation_errors(
                        show_warnings=False, check_sources=True, strict=True)
                if errors:
                    raise SystemExit(1)

        name: str = self.ctx.prg_name
        dest: Path = self.ctx.current_folder.parent / f'{name}.tar.gz'
        files: list[str] = sorted(
            f.name for f in self.ctx.current_folder.iterdir()
            if f.is_file() and not f.name.endswith(SOURCE_EXTENSIONS)
        )

        print(f"Creating submission tarball: {dest}")
        try:
            with tarfile.open(dest, 'w:gz') as tar:
                for file in files:
                    tar.add(self.ctx.current_folder / file, arcname=f'{name}/{file}')
            print(colorize("Package created successfully.", 'green'))
        except (OSError, tarfile.TarError) as err:
            print(colorize(f"Failed to create package: {err}", 'red'))
            raise SystemExit(1) from err
        raise SystemExit(0)

    def show_info(self) -> None:
        """Display all parsed .info file fields in a formatted table and exit.

        Reads the .info file for the current package and prints each field
        (PRGNAM, VERSION, HOMEPAGE, DOWNLOAD, MD5SUM, DOWNLOAD_x86_64 or
        DOWNLOAD_ARM64, REQUIRES, MAINTAINER, EMAIL) with multi-value fields
        indented for readability.
        """
        info_file: Path = Path(self.ctx.current_folder, f'{self.ctx.prg_name}.info')
        if not info_file.is_file():
            print(colorize(f"{PROG}: '{self.ctx.prg_name}.info' not found.", 'red'))
            raise SystemExit(1)

        self.files.read_info_file()
        info = self.ctx.info

        arch_label: str = 'DOWNLOAD_ARM64' if info.arm_support else 'DOWNLOAD_x86_64'
        md5_label: str = 'MD5SUM_ARM64' if info.arm_support else 'MD5SUM_x86_64'

        fields: list[tuple[str, str]] = [
            ('PRGNAM', self.ctx.prg_name),
            ('VERSION', info.version or '—'),
            ('HOMEPAGE', info.homepage or '—'),
            ('DOWNLOAD', info.download_x86 or '—'),
            ('MD5SUM', info.md5sum_x86 or '—'),
            (arch_label, info.download_x86_64 or '—'),
            (md5_label, info.md5sum_x86_64 or '—'),
            ('REQUIRES', info.requires or '—'),
            ('MAINTAINER', self.ctx.info.maintainer or '—'),
            ('EMAIL', self.ctx.info.email or '—'),
        ]

        multi_value: set[str] = {'DOWNLOAD', 'MD5SUM', arch_label, md5_label, 'REQUIRES'}
        label_width: int = max(len(f[0]) for f in fields) + 3
        for label, value in fields:
            label_str: str = f"{label}:"
            if label in multi_value:
                tokens: list[str] = value.split()
            else:
                tokens = [value]
            indent: str = ' ' * (label_width + 1)
            print(f"{label_str:<{label_width}} {tokens[0]}")
            for token in tokens[1:]:
                print(f"{indent}{token}")
        raise SystemExit(0)

    def is_the_sbo_exists(self, names: list[str]) -> None:
        """Check whether each package name exists in the SBo repository.

        Loads package data via the resolver (local repo first, slackbuilds.json
        as fallback). Prints EXISTS (green) or NOT FOUND (red) for each name.
        Exits with code 0 if at least one name was found, or 1 if none were found.

        Args:
            names: List of package names to look up.
        """
        self.resolver.load()
        if not self.ctx.sbo_packages:
            raise SystemExit(1)

        any_found = False
        for name in names:
            print(f"Searching for '{name}' in the SBo repository...", end=' ')
            if name in self.ctx.sbo_packages:
                print(colorize('EXISTS', 'green'))
                any_found = True
            else:
                print(colorize('NOT FOUND', 'red'))

        raise SystemExit(0 if any_found else 1)

    def search_sbo_packages(self, names: list[str]) -> None:
        """Search the SBo repository for packages matching a partial name.

        Loads package data via the resolver (local repo first, slackbuilds.json
        as fallback), then searches ctx.sbo_packages for a case-insensitive
        substring match. Prints matching package names with their category and
        version. Exits with code 0 if at least one match was found, or 1 if
        none were found.

        Args:
            names: List of partial package name queries.
        """
        self.resolver.load()
        if not self.ctx.sbo_packages:
            raise SystemExit(1)

        matches: list[tuple[str, str]] = sorted(
            (pkg, info)
            for pkg, info in self.ctx.sbo_packages.items()
            if any(n.lower() in pkg.lower() for n in names)
        )
        if not matches:
            print(f"No packages found matching {', '.join(repr(n) for n in names)}.")
            raise SystemExit(1)
        print(f"Found {len(matches)} package(s) matching:\n")
        for pkg, info in matches:
            ver: str = self.ctx.sbo_versions.get(pkg, '')
            print(f"  {colorize(pkg, 'green')}"
                  + (f'  {colorize(ver, "cyan")}' if ver else '')
                  + f'  ({info.split()[0]})')
        print()
        raise SystemExit(0)

    def edit_files(self, editor: str | None = None) -> None:
        """Open all existing package files in the configured editor.

        Opens .info, .SlackBuild, slack-desc and README in a single editor
        invocation. Uses the provided editor, falling back to the configured
        maintainer editor, and finally to 'vim'. Editor options from the
        maintainer config are passed as additional arguments.

        Args:
            editor: Editor executable name or path. If None, uses the configured
                maintainer editor or 'vim'.
        """
        editor = editor or self.ctx.maintainer.editor or 'vim'
        options: list[str] = (self.ctx.maintainer.editor_options.split()
                              if self.ctx.maintainer.editor_options else [])
        pkg_files: list[Path] = [
            self.ctx.current_folder / f'{self.ctx.prg_name}.info',
            self.ctx.current_folder / f'{self.ctx.prg_name}.SlackBuild',
            self.ctx.current_folder / 'slack-desc',
            self.ctx.current_folder / 'README',
        ]
        existing: list[str] = [str(f) for f in pkg_files if f.is_file()]
        if not existing:
            print(colorize(
                f"{PROG}: No package files found in '{self.ctx.current_folder}'.", 'red'))
            raise SystemExit(1)
        try:
            subprocess.run([editor] + options + existing, check=False)
        except FileNotFoundError as exc:
            print(colorize(f"{PROG}: Editor '{editor}' not found.", 'red'))
            raise SystemExit(1) from exc
        raise SystemExit(0)

    def _diff_files(self, sbo_pkg_path: Path, local_path: Path, context: int) -> bool:  # pylint: disable=too-many-locals
        """Compute and print a unified diff between SBo repo files and local files.

        Args:
            sbo_pkg_path: Path to the package directory in the SBo repository.
            local_path: Path to the corresponding local package directory.
            context: Number of context lines to include around each diff hunk.

        Returns:
            True if at least one file had differences, False otherwise.
        """
        any_diff: bool = False
        skipped: list[str] = []
        for filename in sorted(f.name for f in sbo_pkg_path.iterdir() if f.is_file()):
            if filename.endswith(SOURCE_EXTENSIONS):
                if (local_path / filename).is_file():
                    skipped.append(filename)
                continue
            local_file: Path = local_path / filename
            if not local_file.is_file():
                continue
            repo_lines = (sbo_pkg_path / filename).read_text(
                encoding='utf-8', errors='replace').splitlines(keepends=True)
            local_lines = local_file.read_text(
                encoding='utf-8', errors='replace').splitlines(keepends=True)
            pkg_prefix: str = f'{sbo_pkg_path.parent.name}/{sbo_pkg_path.name}'
            diff: list[str] = list(difflib.unified_diff(
                repo_lines, local_lines,
                fromfile=f'a/{pkg_prefix}/{filename}',
                tofile=f'b/{pkg_prefix}/{filename}', n=context,
            ))
            if diff:
                any_diff = True
                for line in diff:
                    print(self._colorize_diff_line(line), end='')
                    if not line.endswith('\n'):
                        no_newline: str = '\\ No newline at end of file'
                        print(f'\n{colorize(no_newline, "yellow")}')
        if skipped:
            print(colorize("\nSkipped (sources):", 'yellow'))
            for src in skipped:
                print(src)

        repo_files: set[str] = {f.name for f in sbo_pkg_path.iterdir() if f.is_file()}
        local_files: set[str] = {f.name for f in local_path.iterdir() if f.is_file()}
        new_files: list[str] = sorted(local_files - repo_files)
        if new_files:
            print("\nNew in local (not in SBo repo):")
            for f in new_files:
                print(colorize(f"+{f}", "green"))
            any_diff = True

        return any_diff

    def diff_package(self, names: list[str], context: int = 3) -> None:
        """Show a unified diff between local package files and the SBo repository.

        Args:
            names: Package names to diff. If empty, diffs the current directory.
            context: Number of context lines around each diff hunk (default 3).
        """
        repo_path: Path = self._require_repo_path()
        self.resolver.load()
        from_cwd: bool = not names
        resolved: list[str] = names or [self.ctx.prg_name]
        any_diff: bool = False
        compared: int = 0

        for orig_name in resolved:
            name: str = orig_name.split('/')[-1]
            if name not in self.ctx.sbo_packages:
                if from_cwd:
                    print(colorize(
                        f"{PROG}: No package given."
                        f" Use: {PROG} diff NAME [NAME ...]", 'red'))
                else:
                    print(colorize(f"{PROG}: '{name}' not found in SBo repository.", 'red'))
                continue
            category: str = self.ctx.sbo_packages[name].split()[0]
            sbo_pkg_path: Path = repo_path / category / name
            if not sbo_pkg_path.is_dir():
                print(colorize(f"{PROG}: '{category}/{name}' not found in repo.", 'red'))
                continue
            local_path: Path = (self.ctx.current_folder if from_cwd
                                else self.ctx.current_folder / orig_name)
            if sbo_pkg_path.resolve() == local_path.resolve():
                print(colorize(
                    f"{PROG}: '{name}' local path and repo path are the same directory.\n"
                    f"  Use 'git diff' to see uncommitted changes.", 'yellow'))
                continue
            compared += 1
            if self._diff_files(sbo_pkg_path, local_path, context):
                any_diff = True

        if compared > 0 and not any_diff:
            print("No differences found.")

    @staticmethod
    def _colorize_diff_line(line: str) -> str:
        """Apply ANSI color to a single unified-diff output line.

        Args:
            line: A single line from unified diff output (including newline).

        Returns:
            The line with ANSI color codes applied, or the original if no match.
        """
        if line.startswith('---') or line.startswith('+++'):
            return colorize(line, 'yellow')
        if line.startswith('@@'):
            return colorize(line, 'cyan')
        if line.startswith('-'):
            return colorize(line, 'red')
        if line.startswith('+'):
            return colorize(line, 'green')
        return line

    def rename_package(self, new_name: str) -> None:
        """Rename package files and update all PRGNAM references inside them.

        Args:
            new_name: The new package name to use for files and PRGNAM references.
        """
        folder: Path = self.ctx.current_folder

        old_name: str = self.ctx.prg_name
        info_files: list[Path] = sorted(folder.glob('*.info'))
        if info_files:
            m = re.search(r'^PRGNAM="([^"]+)"',
                          info_files[0].read_text(encoding='utf-8'), re.MULTILINE)
            if m:
                old_name = m.group(1)

        if new_name == old_name:
            print(colorize(
                f"{PROG}: New name is the same as the current name '{old_name}'.", 'red'))
            raise SystemExit(1)

        old_sb: Path = folder / f'{old_name}.SlackBuild'
        old_info: Path = folder / f'{old_name}.info'

        if not old_sb.is_file() and not old_info.is_file():
            print(colorize(f"{PROG}: No files found for '{old_name}'.", 'red'))
            raise SystemExit(1)

        preview: list[str] = self._rename_preview(old_name, new_name, old_sb, old_info)

        print(f"Renaming '{colorize(old_name, 'yellow')}' → '{colorize(new_name, 'green')}':\n")
        for f in preview:
            print(f"  {colorize('>', 'green')} {f}")
        print()
        if not confirm("Continue?", after_newline=False, before_newline=False):
            raise SystemExit(0)

        self._do_rename(old_name, new_name, old_sb, old_info)

        print(f"\n{colorize('Renamed.', 'green')}")
        raise SystemExit(0)

    @staticmethod
    def _rename_preview(old_name: str, new_name: str,
                        old_sb: Path, old_info: Path) -> list[str]:
        """Return list of filenames that would be affected by a rename."""
        preview: list[str] = []
        if old_sb.is_file():
            preview.append(f'{new_name}.SlackBuild')
        if old_info.is_file():
            preview.append(f'{new_name}.info')
        slack_desc: Path = old_sb.parent / 'slack-desc'
        if slack_desc.is_file() and old_name in slack_desc.read_text(encoding='utf-8'):
            preview.append('slack-desc')
        return preview

    def _do_rename(self, old_name: str, new_name: str,
                   old_sb: Path, old_info: Path) -> None:
        """Perform the rename: rewrite file contents and rename files on disk."""
        folder: Path = old_sb.parent
        if old_sb.is_file():
            new_sb: Path = folder / f'{new_name}.SlackBuild'
            content: str = old_sb.read_text(encoding='utf-8')
            content = content.replace(f'PRGNAM={old_name}', f'PRGNAM={new_name}')
            content = re.sub(
                r'^(#\s*Slackware build script for\s+)<?' + re.escape(old_name) + r'>?',
                r'\g<1><' + new_name + '>', content, flags=re.MULTILINE)
            old_sb.rename(new_sb)
            new_sb.write_text(content, encoding='utf-8')
        if old_info.is_file():
            new_info: Path = folder / f'{new_name}.info'
            content = old_info.read_text(encoding='utf-8')
            content = content.replace(f'PRGNAM="{old_name}"', f'PRGNAM="{new_name}"')
            old_info.rename(new_info)
            new_info.write_text(content, encoding='utf-8')
        self._rename_slack_desc(folder, old_name, new_name)

    def _rename_slack_desc(self, folder: Path, old_name: str, new_name: str) -> bool:
        """Update the slack-desc file by replacing old_name with new_name throughout.

        Args:
            folder: Directory containing the slack-desc file.
            old_name: The current package name to replace.
            new_name: The new package name to insert.

        Returns:
            True if the file was modified, False if unchanged or missing.
        """
        slack_desc: Path = folder / 'slack-desc'
        if not slack_desc.is_file():
            return False
        content: str = slack_desc.read_text(encoding='utf-8')
        prefix_match = re.search(r'^([\w][\w-]*):', content, re.MULTILINE)
        sd_name: str = prefix_match.group(1) if prefix_match else old_name
        updated: str = re.sub(r'\b' + re.escape(sd_name) + r'\b', new_name, content)
        if updated == content:
            return False
        slack_desc.write_text(updated, encoding='utf-8')
        return True

    def clean_sources(self) -> bool:
        """Remove downloaded source tarballs from the current package directory.

        Lists all files matching SOURCE_EXTENSIONS, prompts the user for
        confirmation, then delegates to _remove_tarballs for the actual deletion.
        Used both as a standalone command and as a cleanup step before packaging.

        Returns:
            True if at least one source file was removed, False otherwise.
        """
        tarballs: list[Path] = sorted(
            f for f in self.ctx.current_folder.iterdir()
            if f.is_file() and f.name.endswith(SOURCE_EXTENSIONS)
        )

        if not tarballs:
            print("No source tarballs found.")
            return False

        print("The following files will be removed:\n")
        for f in tarballs:
            print(f"  {f.name}")

        if not confirm("Remove?"):
            return False

        self._remove_tarballs()
        for f in tarballs:
            print(f"  {colorize('REMOVED', 'red')}  {f.name}")
        print()
        return True

    # -- dependency tree -----------------------------------------------------

    def deps_tree(self, name: str, tree: bool = False) -> None:  # pylint: disable=too-many-locals
        """Display the dependency list or full tree for a package.

        Resolves dependencies recursively from the cached SLACKBUILDS.TXT data.
        Cycles are detected and silently stopped. Special tokens ('%README%')
        and empty strings in REQUIRES are ignored.

        Args:
            name:  Package name whose dependencies to display.
            tree:  If True, print a full recursive tree with branch connectors.
                   If False (default), print a flat list: direct deps with a
                   connector, transitive deps indented without connectors.
        """
        self.resolver.load()
        if name not in self.ctx.sbo_packages:
            print(colorize(f"{PROG}: '{name}' not found in SBo repository.", 'red'))
            raise SystemExit(1)

        direct_deps: list[str] = self.files.fetch_sbo_requires(name)
        print(f"{colorize(name, 'green')}:")
        if not direct_deps:
            print("  (no dependencies)")
            return

        if tree:
            seen: set[str] = {name}
            all_deps: set[str] = set()
            for idx, dep in enumerate(direct_deps):
                _print_tree_branch(dep, '  ', idx == len(direct_deps) - 1,
                                   seen, all_deps, self.files.fetch_sbo_requires)

            print()
            n_direct: int = len(set(direct_deps))
            n_transitive: int = len(all_deps - set(direct_deps))
            print(f"  {n_direct} direct, {n_transitive} transitive.")
        else:
            ordered: list[str] = []
            seen_flat: set[str] = {name}

            def _collect(pkg: str) -> None:
                if pkg in seen_flat:
                    return
                seen_flat.add(pkg)
                ordered.append(pkg)
                for dep in self.files.fetch_sbo_requires(pkg):
                    _collect(dep)

            for dep in direct_deps:
                _collect(dep)

            direct_set: set[str] = set(direct_deps)
            for idx, dep in enumerate(direct_deps):
                connector: str = '└─' if idx == len(direct_deps) - 1 else '├─'
                print(f"  {connector} {colorize(dep, 'cyan')}")
            for dep in ordered:
                if dep not in direct_set:
                    print(f"     {colorize(dep, 'cyan')}")

            print()
            n_dir: int = len(direct_set)
            n_trans: int = len(ordered) - n_dir
            print(f"{n_dir} direct, {n_trans} transitive.")

    def reverse_deps(self, name: str, tree: bool = False) -> None:  # pylint: disable=too-many-locals
        """Display packages that depend on the given package (reverse dependencies).

        Scans the cached sbo_requires mapping to find all packages that list
        NAME in their REQUIRES. Cycles are detected and silently stopped.

        Args:
            name: Package name to reverse-resolve.
            tree: If True, print a full recursive reverse tree. If False
                  (default), print a flat list of direct dependees.
        """
        self.resolver.load()
        if name not in self.ctx.sbo_packages:
            print(colorize(f"{PROG}: '{name}' not found in SBo repository.", 'red'))
            raise SystemExit(1)

        def _direct_dependees(pkg: str) -> list[str]:
            return sorted(
                p for p, reqs in self.ctx.sbo_requires.items() if pkg in reqs
            )

        direct: list[str] = _direct_dependees(name)
        print(f"{colorize(name, 'green')}:")
        if not direct:
            print("  (no reverse dependencies)")
            return

        if tree:
            seen: set[str] = {name}
            all_rdeps: set[str] = set()
            for idx, dep in enumerate(direct):
                _print_tree_branch(dep, '  ', idx == len(direct) - 1,
                                   seen, all_rdeps, _direct_dependees)

            print()
            n_direct: int = len(set(direct))
            n_transitive: int = len(all_rdeps - set(direct))
            print(f"  {n_direct} direct, {n_transitive} transitive.")
        else:
            ordered: list[str] = []
            seen_flat: set[str] = {name}

            def _collect(pkg: str) -> None:
                if pkg in seen_flat:
                    return
                seen_flat.add(pkg)
                ordered.append(pkg)
                for rdep in _direct_dependees(pkg):
                    _collect(rdep)

            for dep in direct:
                _collect(dep)

            direct_set: set[str] = set(direct)
            for idx, dep in enumerate(direct):
                con: str = '└─' if idx == len(direct) - 1 else '├─'
                print(f"  {con} {colorize(dep, 'cyan')}")
            for dep in ordered:
                if dep not in direct_set:
                    print(f"     {colorize(dep, 'cyan')}")

            print()
            n_dir: int = len(direct_set)
            n_trans: int = len(ordered) - n_dir
            print(f"{n_dir} direct, {n_trans} transitive.")

    # -- copy to repo --------------------------------------------------------

    def copy_package(self) -> None:
        """Copy local package files to the corresponding directory in the SBo repository.

        Reads the package name from the current directory's .info file, locates
        the matching directory in SBO_REPO_PATH, lists the files that will be
        copied (excluding source tarballs), prompts for confirmation, and
        performs the copy. Exits with an error if SBO_REPO_PATH is not
        configured or the package is not found in the repository.
        """
        repo_path: Path = self._require_repo_path()
        self.resolver.load()

        name: str = self.ctx.prg_name
        if name not in self.ctx.sbo_packages:
            print(colorize(f"{PROG}: '{name}' not found in SBo repository.", 'red'))
            raise SystemExit(1)

        category: str = self.ctx.sbo_packages[name].split()[0]
        dest: Path = repo_path / category / name
        if not dest.is_dir():
            print(colorize(f"{PROG}: '{category}/{name}' not found in repo.", 'red'))
            raise SystemExit(1)

        local_path: Path = self.ctx.current_folder
        all_files: list[Path] = sorted(f for f in local_path.iterdir() if f.is_file())
        files: list[Path] = [f for f in all_files if not f.name.endswith(SOURCE_EXTENSIONS)]
        skipped: list[Path] = [f for f in all_files if f.name.endswith(SOURCE_EXTENSIONS)]

        if not files:
            print(colorize(f"{PROG}: No files to copy in '{local_path}'.", 'red'))
            raise SystemExit(1)

        print(f"Copying to {colorize(str(dest), 'cyan')}:\n")
        for f in files:
            print(f"  {colorize(f.name, 'yellow')}")
        if skipped:
            print(colorize("\nSkipped (sources):", 'yellow'))
            for f in skipped:
                print(f"  {f.name}")

        if not confirm("\nProceed?", before_newline=False):
            raise SystemExit(0)

        for f in files:
            try:
                shutil.copy2(f, dest / f.name)
                print(f"  {colorize('COPIED', 'green')}  {f.name}")
            except OSError as e:
                print(colorize(f"{PROG}: failed to copy '{f.name}': {e}", 'red'))
                raise SystemExit(1) from e

        print()
        raise SystemExit(0)
