#!/bin/bash

#############################################################
#### Expand Drive                                        ####
#### Auto-expand partitions and filesystems              ####
####                                                     ####
#### Author: Phil Connor                                 ####
#### Contact: contact@mylinux.work                       ####
#### License: MIT                                        ####
#### Version: 2.4                                        ####
####                                                     ####
#### Usage: sudo ./expand-drive.sh                       ####
#############################################################
# v2.4 changes:
#   - Fixed: grep in pipeline crashes under set -euo pipefail when no matches found. Added || true guard
#############################################################

# Set strict error handling:
# -e: Exit immediately if a command exits with a non-zero status
# -u: Treat unset variables as an error when substituting
# -o pipefail: The return value of a pipeline is the status of the last command to exit with a non-zero status
set -euo pipefail

# Constants - Define paths to required system binaries (use command names, let PATH resolve)
readonly BLKID_PATH="blkid"           # Tool to locate/print block device attributes
readonly LSBLK_PATH="lsblk"            # Tool to list block devices
readonly LOG_FILE="/var/log/expand_drive.log"   # Location for script log output

# Configuration - Runtime behavior settings
readonly DRY_RUN=${DRY_RUN:-false}                                  # If true, show what would be done without making changes
readonly REQUIRED_COMMANDS=("growpart" "xfs_growfs" "resize2fs")    # Commands that must be available
readonly SUPPORTED_FILESYSTEMS=("xfs" "ext2" "ext3" "ext4")         # Filesystem types we can expand

# Exit codes - Standardized exit status values
readonly EXIT_SUCCESS=0          # Script completed successfully
readonly EXIT_ERROR=1            # General error occurred
readonly EXIT_ROOT_REQUIRED=2    # Script must be run as root user
readonly EXIT_MISSING_DEPS=3     # Required dependencies are missing

# Function to log messages with timestamp to both console and log file
log_message() {
    echo "$(date): $1" | tee -a "$LOG_FILE"
}

# Function to log error messages with timestamp to both console, log file, and stderr
log_error() {
    echo "$(date): ERROR: $1" | tee -a "$LOG_FILE" >&2
}

# Function to check if a command exists in the system PATH
command_exists() {
    command -v "$1" >/dev/null 2>&1
}

# Function to handle script interruption (SIGINT/SIGTERM) and perform cleanup
cleanup() {
    # shellcheck disable=SC2317  # Suppress warning about unreachable code
    log_message "Script interrupted, cleaning up..."
    # shellcheck disable=SC2317  # Suppress warning about unreachable code
    exit "$EXIT_ERROR"
}

# Function to validate prerequisites before script execution
validate_prerequisites() {
    # Check if script is run as root (required for partition/filesystem operations)
    if [ "$(id -u)" -ne 0 ]; then
        echo "Error: This script must be run as root"
        exit "$EXIT_ROOT_REQUIRED"
    fi

    # Ensure log directory exists and is writable
    local log_dir
    log_dir=$(dirname "$LOG_FILE")
    if [ ! -d "$log_dir" ]; then
        mkdir -p "$log_dir" || {
            echo "Error: Cannot create log directory $log_dir"
            exit "$EXIT_ERROR"
        }
    fi

    # Verify all required system commands are available
    for cmd in "${REQUIRED_COMMANDS[@]}"; do
        if ! command_exists "$cmd"; then
            log_error "Required command '$cmd' not found. Please install it."
            exit "$EXIT_MISSING_DEPS"
        fi
    done
}

# Function to check if filesystem type is supported by this script
is_supported_filesystem() {
    local fs_type="$1"
    # Loop through supported filesystem types array
    for supported in "${SUPPORTED_FILESYSTEMS[@]}"; do
        if [[ "$fs_type" == "$supported" ]]; then
            return 0  # Filesystem type is supported
        fi
    done
    return 1  # Filesystem type is not supported
}

# Function to expand filesystem based on type (XFS or EXT variants)
expand_filesystem() {
    local partition="$1"     # Block device path (e.g., /dev/sda1)
    local fs_type="$2"       # Filesystem type (xfs, ext2, ext3, ext4)
    local mount_point="$3"   # Where the filesystem is mounted

    # Validate filesystem type is one we support
    if ! is_supported_filesystem "$fs_type"; then
        log_error "Unsupported filesystem type $fs_type on $partition"
        return 1
    fi

    # Handle different filesystem types with appropriate expansion commands
    case $fs_type in
    "xfs")
        log_message "Expanding XFS filesystem on $partition"
        if [ "$DRY_RUN" = "true" ]; then
            log_message "DRY RUN: Would expand XFS filesystem on $partition"
            return 0
        # XFS uses xfs_growfs and requires the mount point as argument
        elif xfs_growfs "$mount_point" >/dev/null 2>&1; then
            log_message "Successfully expanded XFS filesystem on $partition"
            return 0
        else
            log_error "Failed to expand XFS filesystem on $partition"
            return 1
        fi
        ;;
    "ext2" | "ext3" | "ext4")
        log_message "Expanding EXT filesystem on $partition"
        if [ "$DRY_RUN" = "true" ]; then
            log_message "DRY RUN: Would expand EXT filesystem on $partition"
            return 0
        # EXT filesystems use resize2fs and require the device path as argument
        elif resize2fs "$partition" >/dev/null 2>&1; then
            log_message "Successfully expanded EXT filesystem on $partition"
            return 0
        else
            log_error "Failed to expand EXT filesystem on $partition"
            return 1
        fi
        ;;
    esac
}

# Function to expand partition to use available disk space
expand_partition() {
    local disk="$1"        # Parent disk device (e.g., /dev/sda)
    local partition="$2"   # Partition device (e.g., /dev/sda1)
    local part_num="$3"    # Partition number (e.g., 1)

    # Check if partition can be expanded using growpart dry-run
    if ! growpart "$disk" "$part_num" --dry-run 2>/dev/null; then
        log_message "Partition $partition doesn't need expansion or cannot be expanded, skipping..."
        return 1  # Not an error, just nothing to do
    fi

    # Perform the actual partition expansion
    if [ "$DRY_RUN" = "true" ]; then
        log_message "DRY RUN: Would expand partition $partition"
        return 0
    elif growpart "$disk" "$part_num" >/dev/null 2>&1; then
        log_message "Successfully expanded partition $partition"
        return 0
    else
        log_error "Failed to expand partition $partition"
        return 1
    fi
}

# Set up signal trap to handle interruptions gracefully
trap cleanup INT TERM

# Initialize script by validating prerequisites
validate_prerequisites

# Function to process a single partition (expand partition and filesystem)
process_partition() {
    local partition="$1"  # Partition device path (e.g., /dev/sda1)
    local disk="$2"       # Parent disk device path (e.g., /dev/sda)

    log_message "Processing partition $partition"

    # Check if the filesystem is currently mounted (required for filesystem expansion)
    local mount_point
    mount_point=$(findmnt -n -o TARGET "$partition" 2>/dev/null)
    if [ -z "$mount_point" ]; then
        log_message "Warning: $partition is not mounted, skipping filesystem resize"
        return 0
    fi

    # Extract partition number from device path (e.g., extract "1" from "/dev/sda1")
    local part_num
    part_num=$(echo "$partition" | { grep -o '[0-9]\+$' || true; } | tail -1)
    if [ -z "$part_num" ]; then
        log_error "Could not extract partition number from $partition"
        return 1
    fi

    # First expand the partition to use available disk space
    if ! expand_partition "$disk" "$partition" "$part_num"; then
        return 0 # Not an error if partition doesn't need expansion
    fi

    # Detect the filesystem type using blkid
    local fs_type
    fs_type=$($BLKID_PATH -s TYPE -o value "$partition")
    if [ -z "$fs_type" ]; then
        log_message "Warning: Could not detect filesystem type for $partition, skipping..."
        return 0
    fi

    # Get current filesystem size before expansion
    local current_size
    current_size=$(df -h "$mount_point" | awk 'NR==2 {print $2}')
    log_message "Current filesystem size on $partition: $current_size"
    
    # Expand the filesystem to use the newly available partition space
    expand_filesystem "$partition" "$fs_type" "$mount_point"
    
    # Show new size after expansion
    local new_size
    new_size=$(df -h "$mount_point" | awk 'NR==2 {print $2}')
    log_message "New filesystem size on $partition: $new_size"
}

# Function to process a disk with direct filesystem (no partitions)
process_direct_filesystem() {
    local disk="$1"         # Disk device path (e.g., /dev/nvme3n1)
    local mount_point="$2"  # Where the filesystem is mounted
    
    log_message "Processing direct filesystem on $disk mounted at $mount_point"
    
    # Detect the filesystem type using blkid
    local fs_type
    fs_type=$($BLKID_PATH -s TYPE -o value "$disk")
    if [ -z "$fs_type" ]; then
        log_message "Warning: Could not detect filesystem type for $disk, skipping..."
        return 0
    fi
    
    # Get current filesystem size before expansion
    local current_size
    current_size=$(df -h "$mount_point" | awk 'NR==2 {print $2}')
    log_message "Current filesystem size on $disk: $current_size"
    
    # Expand the filesystem to use the full disk space
    expand_filesystem "$disk" "$fs_type" "$mount_point"
    
    # Show new size after expansion
    local new_size
    new_size=$(df -h "$mount_point" | awk 'NR==2 {print $2}')
    log_message "New filesystem size on $disk: $new_size"
}

# Function to process all partitions on a single disk
process_disk() {
    local disk="$1"  # Disk device path (e.g., /dev/sda)

    log_message "Checking partitions on $disk..."

    # Get list of partitions for the current disk using lsblk
    # Filter for partition type and extract device names
    local partitions
    local lsblk_output
    lsblk_output=$($LSBLK_PATH -pln -o NAME,TYPE "$disk" 2>&1) || {
        log_error "lsblk command failed for $disk: $lsblk_output"
        return 1
    }
    partitions=$(echo "$lsblk_output" | grep "part" | cut -d' ' -f1 || true)
    
    if [ -z "$partitions" ]; then
        # Check if the disk itself has a filesystem (no partition table)
        local mount_point
        mount_point=$(findmnt -n -o TARGET "$disk" 2>/dev/null)
        if [ -n "$mount_point" ]; then
            log_message "No partitions found on $disk, but disk has direct filesystem. Processing disk directly..."
            process_direct_filesystem "$disk" "$mount_point"
        else
            log_message "No partitions found on $disk, skipping..."
        fi
        return 0
    fi

    # Process each partition found on this disk
    for partition in $partitions; do
        process_partition "$partition" "$disk"
    done
}

# Main execution function - orchestrates the entire drive expansion process
main() {
    log_message "Starting drive expansion process..."

    # Get list of all disk devices in the system using lsblk
    # Filter for disk type and extract device names
    local devices
    devices=$($LSBLK_PATH -pln -o NAME,TYPE | { grep "disk" || true; } | cut -d' ' -f1)

    # Verify we found at least one disk device
    if [ -z "$devices" ]; then
        log_error "No disk devices found"
        exit "$EXIT_ERROR"
    fi

    # Process each disk device found
    for disk in $devices; do
        # Verify device is actually a block device before processing
        if [ ! -b "$disk" ]; then
            log_error "Device $disk is not a block device, skipping..."
            continue
        fi
        process_disk "$disk"
    done

    log_message "Drive expansion completed"
    exit "$EXIT_SUCCESS"
}

# Execute the main function to start the script
main
