#!/usr/bin/env python3
# SPDX-License-Identifier: LGPL-2.1-or-later

"""
Find unused symbols in a shared library.

This script analyzes a shared library and a list of executables that link
against it to determine which publicly exported symbols from the library
are not used by any of the executables or by the library itself internally.

The script checks for symbol usage in three ways:
1. Internal library references: Uses objdump -R to find relocations within
   the library that reference its own exported symbols
2. Executable dependencies: Uses nm to find undefined symbols in executables
   that match the library's exported symbols
3. Cross-references: Identifies symbols used across all provided binaries

This comprehensive approach ensures that symbols used internally by the
library are not incorrectly marked as unused.
"""

import argparse
import subprocess
import sys
from pathlib import Path


def get_exported_symbols(library_path):
    """
    Extract all exported (public) symbols from a shared library.

    Public API symbols (those starting with 'sd_') are excluded from the analysis
    since they cannot be removed or made private due to API compatibility requirements.

    Returns a set of symbol names that are defined and exported by the library.
    """
    try:
        result = subprocess.run(
            ['nm', '--dynamic', '--defined-only', '--extern-only', library_path],
            capture_output=True,
            text=True,
            check=True,
        )
    except subprocess.CalledProcessError as e:
        print(f'Error: Failed to run nm on {library_path}: {e}', file=sys.stderr)
        sys.exit(1)
    except FileNotFoundError:
        print("Error: 'nm' command not found. Please install binutils.", file=sys.stderr)
        sys.exit(1)

    symbols = set()
    for line in result.stdout.splitlines():
        parts = line.split()
        if len(parts) >= 3:
            # Format: address type name
            symbol_type = parts[1]
            symbol_name = parts[2]
            # Include text (T) and data (D, B, R) symbols
            if symbol_type in ('T', 'D', 'B', 'R', 'W'):
                # Strip version information (e.g., @@SD_SHARED or @SD_SHARED)
                symbol_name = symbol_name.split('@')[0]
                # Skip public API symbols (those starting with sd_)
                if symbol_name.startswith('sd_'):
                    continue
                symbols.add(symbol_name)

    return symbols


def get_undefined_symbols(executable_path):
    """
    Extract all undefined symbols from an executable.

    These are symbols that the executable expects to be provided by
    shared libraries it links against.
    """
    try:
        result = subprocess.run(
            ['nm', '--dynamic', '--undefined-only', executable_path],
            capture_output=True,
            text=True,
            check=True,
        )
    except subprocess.CalledProcessError as e:
        print(f'Warning: Failed to run nm on {executable_path}: {e}', file=sys.stderr)
        return set()
    except FileNotFoundError:
        print("Error: 'nm' command not found. Please install binutils.", file=sys.stderr)
        sys.exit(1)

    symbols = set()
    for line in result.stdout.splitlines():
        parts = line.split()
        if len(parts) >= 2:
            # Format: type name (no address for undefined symbols)
            symbol_name = parts[1]
            # Strip version information (e.g., @SD_SHARED)
            symbol_name = symbol_name.split('@')[0]
            symbols.add(symbol_name)

    return symbols


def verify_executable_links_library(executable_path, library_name):
    """
    Verify that an executable actually links against the given library.

    Returns True if the executable links against a library with the given name.
    """
    try:
        result = subprocess.run(
            ['ldd', executable_path],
            capture_output=True,
            text=True,
            check=True,
        )
    except (subprocess.CalledProcessError, FileNotFoundError):
        # If ldd fails or doesn't exist, we'll skip the verification
        return True

    # Check if library_name appears in the ldd output
    for line in result.stdout.splitlines():
        if library_name in line:
            return True

    return False


def get_library_internal_references(library_path, exported_symbols):
    """
    Find which exported symbols are referenced internally within the library itself.

    This uses objdump to look for relocations that reference the exported symbols.
    """
    try:
        result = subprocess.run(
            ['objdump', '-R', library_path],
            capture_output=True,
            text=True,
            check=True,
        )
    except subprocess.CalledProcessError as e:
        print(f'Warning: Failed to run objdump on {library_path}: {e}', file=sys.stderr)
        return set()
    except FileNotFoundError:
        print(
            "Warning: 'objdump' command not found. Internal references won't be detected.",
            file=sys.stderr,
        )
        return set()

    internal_refs = set()
    for line in result.stdout.splitlines():
        parts = line.split()
        if len(parts) >= 3:
            # objdump -R format: offset type symbol
            # The symbol is typically the last field
            symbol_name = parts[-1]
            # Strip version information
            symbol_name = symbol_name.split('@')[0]
            # Only include if it's one of our exported symbols
            if symbol_name in exported_symbols:
                internal_refs.add(symbol_name)

    return internal_refs


def find_unused_symbols(library_path, executable_paths, verify_linkage=True):
    """
    Find symbols exported by the library that are not used by any executable.

    Args:
        library_path: Path to the shared library
        executable_paths: List of paths to executables
        verify_linkage: Whether to verify executables link against the library

    Returns:
        Tuple of (unused_symbols, exported_symbols, used_symbols)
    """
    library_name = Path(library_path).name

    # Get all exported symbols from the library (excluding public API symbols)
    exported_symbols = get_exported_symbols(library_path)

    if not exported_symbols:
        print(f'Warning: No exported symbols found in {library_path}', file=sys.stderr)
        return set(), set(), set()

    # Collect all symbols used by the executables
    used_symbols = set()

    # First, check if the library references its own exported symbols internally
    internal_refs = get_library_internal_references(library_path, exported_symbols)
    used_symbols.update(internal_refs)

    for exe_path in executable_paths:
        # Optionally verify linkage
        if verify_linkage and not verify_executable_links_library(exe_path, library_name):
            print(f'Warning: {exe_path} does not appear to link against {library_name}', file=sys.stderr)

        undefined_symbols = get_undefined_symbols(exe_path)
        # Only count symbols that are actually exported by our library
        used_symbols.update(undefined_symbols & exported_symbols)

    # Find unused symbols
    unused_symbols = exported_symbols - used_symbols

    return unused_symbols, exported_symbols, used_symbols


def main():
    parser = argparse.ArgumentParser(
        description='Find unused exported symbols in a shared library',
    )
    parser.add_argument(
        'library',
        help='Path to the shared library to analyze',
    )
    parser.add_argument(
        'executables',
        nargs='+',
        help='Paths to executables that link against the library',
    )
    parser.add_argument(
        '--no-verify-linkage',
        action='store_true',
        help='Skip verification that executables actually link against the library',
    )
    parser.add_argument(
        '--show-used',
        action='store_true',
        help='Also show used symbols',
    )
    parser.add_argument(
        '--stats-only',
        action='store_true',
        help='Only show statistics, not individual symbols',
    )

    args = parser.parse_args()

    # Verify library exists
    library_path = Path(args.library)
    if not library_path.exists():
        print(f'Error: Library not found: {library_path}', file=sys.stderr)
        sys.exit(1)

    # Verify executables exist
    executable_paths = []
    for exe in args.executables:
        exe_path = Path(exe)
        if not exe_path.exists():
            print(f'Warning: Executable not found: {exe_path}', file=sys.stderr)
        else:
            executable_paths.append(str(exe_path))

    if not executable_paths:
        print('Error: No valid executables provided', file=sys.stderr)
        sys.exit(1)

    # Analyze symbols
    unused, exported, used = find_unused_symbols(
        str(library_path), executable_paths, verify_linkage=not args.no_verify_linkage
    )

    # Print results
    print(f'Analysis of {library_path.name}')
    print('=' * 70)
    print(f'Total exported symbols: {len(exported)}')
    print("  (excluding public API symbols starting with 'sd_')")
    print(f'Used symbols: {len(used)}')
    print(f'Unused symbols: {len(unused)}')
    print(f'Usage rate: {len(used) / len(exported) * 100:.1f}%' if exported else 'N/A')
    print()

    if not args.stats_only:
        if unused:
            print('Unused symbols:')
            print('-' * 70)
            for symbol in sorted(unused):
                print(f'  {symbol}')
            print()
        else:
            print('All exported symbols are used!')
            print()

        if args.show_used and used:
            print('Used symbols:')
            print('-' * 70)
            for symbol in sorted(used):
                print(f'  {symbol}')
            print()

    # Exit with non-zero if there are unused symbols (useful for CI)
    sys.exit(0 if not unused else 1)


if __name__ == '__main__':
    main()
