#!/usr/bin/env bash
#
# Updates subfolders git projects.
#
# Actions taken: pull with rebase, autostashes own changes
# and prunes branches no longer in the remote.
#
# Copyright (c) 2023 Ismo Vuorinen. All Rights Reserved.
# License: MIT <https://opensource.org/license/mit/>

set -uo pipefail

# Script version
VERSION="1.0.0"

# Default settings
VERBOSE=0
QUIET=0
EXCLUDE_DIRS=""
CLEANUP=0
CONFIG_FILE=""
LOG_FILE=""

# Define color variables if terminal supports it
if [[ -t 1 ]]; then
  RED='\033[0;31m'
  GREEN='\033[0;32m'
  YELLOW='\033[0;33m'
  BLUE='\033[0;34m'
  CYAN='\033[0;36m'
  NC='\033[0m' # No Color
else
  RED=''
  GREEN=''
  YELLOW=''
  BLUE=''
  CYAN=''
  NC=''
fi

# Counters
TOTAL=0
SUCCESS=0
FAILED=0
CONFLICTS=0
UPDATED=0
PROCESSED=0
SKIPPED=0
UNTRACKED=0
UNMERGED=0
BRANCHES_CLEANED=0

# Function to display help message
show_help()
{
  BIN=$(basename "$0")
  cat << EOF
Usage: $BIN [OPTIONS]

Updates all git repositories in subdirectories.

Options:
  --help, -h      Display this help message and exit
  --version, -v   Display version information and exit
  --verbose       Display detailed output
  --quiet, -q     Suppress all output except errors
  --exclude DIR   Exclude directory from updates (can be used multiple times)
  --cleanup       Remove local branches that have been merged into current branch
  --config FILE   Read options from configuration file
  --log FILE      Log details and errors to FILE

Environment variables:
  VERBOSE         Set to 1 to enable verbose output
  EXCLUDE_DIRS    Space-separated list of directories to exclude

Examples:
  $BIN              Update all git repositories
  $BIN --verbose    Update with detailed output
  $BIN --exclude node_modules --exclude vendor
                               Update repositories but skip node_modules
                               and vendor dirs
  $BIN --cleanup    Update and clean up merged branches
  $BIN --config ~/.gitupdate.conf
                               Use options from config file
EOF
  exit 0
}

# Function to display version
show_version()
{
  echo "$(basename "$0") version $VERSION"
  exit 0
}

# Function to log messages
# $1 - level (string: INFO, WARNING, ERROR)
# $2 - message (string)
log()
{
  local level message timestamp

  level="$1"
  message="$2"
  timestamp=$(date +"%Y-%m-%d %H:%M:%S")

  if [[ -n "$LOG_FILE" ]]; then
    echo "[$timestamp] [$level] $message" >> "$LOG_FILE"
  fi

  # For errors, also log to stderr if in verbose mode
  if [[ "$level" == "ERROR" && "$VERBOSE" -eq 1 && "$QUIET" -eq 0 ]]; then
    echo -e "${RED}[$timestamp] [$level] $message${NC}" >&2
  fi
}

# Process command-line arguments
process_args()
{
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --help | -h)
        show_help
        ;;
      --version | -v)
        show_version
        ;;
      --verbose)
        VERBOSE=1
        ;;
      --quiet | -q)
        QUIET=1
        ;;
      --exclude)
        if [[ -n "$2" ]]; then
          EXCLUDE_DIRS="$EXCLUDE_DIRS $2"
          shift
        else
          echo "Error: --exclude requires a directory argument" >&2
          exit 1
        fi
        ;;
      --cleanup)
        CLEANUP=1
        ;;
      --config)
        if [[ -n "$2" && -f "$2" ]]; then
          CONFIG_FILE="$2"
          shift
        else
          echo "Error: --config requires a valid file argument" >&2
          exit 1
        fi
        ;;
      --log)
        if [[ -n "$2" ]]; then
          LOG_FILE="$2"
          shift
        else
          echo "Error: --log requires a file argument" >&2
          exit 1
        fi
        ;;
      *)
        echo "Unknown option: $1" >&2
        echo "Use --help for usage information" >&2
        exit 1
        ;;
    esac
    shift
  done

  # Process config file if specified
  if [[ -n "$CONFIG_FILE" && -f "$CONFIG_FILE" ]]; then
    log "INFO" "Reading configuration from $CONFIG_FILE"
    while IFS= read -r line || [[ -n "$line" ]]; do
      # Skip comments and empty lines
      [[ "$line" =~ ^[[:space:]]*# ]] && continue
      [[ -z "${line// /}" ]] && continue

      # Process each option from the config file
      option=$(echo "$line" | awk '{print $1}')
      value=$(echo "$line" | cut -d' ' -f2-)

      case "$option" in
        exclude) EXCLUDE_DIRS="$EXCLUDE_DIRS $value" ;;
        verbose) VERBOSE=1 ;;
        quiet) QUIET=1 ;;
        cleanup) CLEANUP=1 ;;
        log) LOG_FILE="$value" ;;
        *) log "WARNING" "Unknown option in config file: $option" ;;
      esac
    done < "$CONFIG_FILE"
  fi

  # Environment variables override command-line options
  [[ -n "${VERBOSE:-}" && "$VERBOSE" -eq 1 ]] && VERBOSE=1
  # shellcheck disable=SC2269
  [[ -n "${EXCLUDE_DIRS:-}" ]] && EXCLUDE_DIRS="${EXCLUDE_DIRS}"

  # Initialize log file if specified
  if [[ -n "$LOG_FILE" ]]; then
    # Create log directory if it doesn't exist
    mkdir -p "$(dirname "$LOG_FILE")" 2> /dev/null || true
    # Initialize log file
    echo "[$(date +"%Y-%m-%d %H:%M:%S")] [INFO] Started git-update-dirs version $VERSION" > "$LOG_FILE"
  fi
}

# Terminal width for progress bar
TERM_WIDTH=$(tput cols 2> /dev/null || echo 120)
PROGRESS_WIDTH=$((TERM_WIDTH - 40))
MAX_DIR_LENGTH=$((TERM_WIDTH - PROGRESS_WIDTH - 25)) # Add 5 for extra padding

# Last status message, used for clearing properly
LAST_STATUS_LENGTH=0

# Function to print messages if VERBOSE is enabled
# $1 - message (string)
msg()
{
  local message
  message="$1"
  if [[ "$VERBOSE" -eq 1 && "$QUIET" -eq 0 ]]; then
    echo "$message"
    [[ -n "$LOG_FILE" ]] && log "INFO" "$message"
  elif [[ -n "$LOG_FILE" ]]; then
    log "DEBUG" "$message"
  fi
}

# Function to print normal output unless QUIET is enabled
# $1 - message (string)
print()
{
  local message
  message="$1"
  if [[ "$QUIET" -eq 0 ]]; then
    echo -e "$message"
    [[ -n "$LOG_FILE" ]] && log "INFO" "$message"
  elif [[ -n "$LOG_FILE" ]]; then
    log "INFO" "$message"
  fi
}

# Function to display progress bar
# $1 - current (int)
# $2 - total (int)
# $3 - status message (string)
show_progress()
{
  [[ "$QUIET" -eq 1 ]] && return

  local current total status percent filled empty

  current=$1
  total=$2
  status=$3

  # If TERM_WIDTH is less than LAST_STATUS_LENGTH set TERM_WIDTH
  # to it.
  if [[ $TERM_WIDTH -lt $LAST_STATUS_LENGTH ]]; then
    TERM_WIDTH=$LAST_STATUS_LENGTH
  fi

  # Clear the entire line before updating to avoid artifacts
  printf "\r%-${TERM_WIDTH}s" " "

  # Avoid division by zero
  if [[ "$total" -eq 0 ]]; then
    percent=0
  else
    percent=$((current * 100 / total))
  fi

  filled=$((percent * PROGRESS_WIDTH / 100))
  # Ensure filled doesn't exceed PROGRESS_WIDTH
  [[ $filled -gt $PROGRESS_WIDTH ]] && filled=$PROGRESS_WIDTH
  empty=$((PROGRESS_WIDTH - filled))

  # Truncate status message if too long
  if [[ ${#status} -gt $MAX_DIR_LENGTH ]]; then
    status="...${status:$((${#status} - MAX_DIR_LENGTH + 4))}"
  fi

  # Pad the status message to ensure consistent width and add extra space
  printf -v padded_status "%-${MAX_DIR_LENGTH}s" "$status"

  # Create and display the progress bar with fixed width for percentage and colors
  printf "\r[${BLUE}%s${NC}%s] ${GREEN}%3d%%${NC} ${CYAN}%s${NC}" \
    "$(printf '#%.0s' $(seq 1 $filled))" \
    "$(printf ' %.0s' $(seq 1 $empty))" \
    "$percent" \
    "$padded_status"

  # Store the length of the current status
  LAST_STATUS_LENGTH=${#status}

  # Log progress if logging is enabled
  [[ -n "$LOG_FILE" ]] && log "DEBUG" "Progress: $percent% - $status"
}

# Is the directory path excluded?
# $1: Directory path
# Return 0 if the directory should be skipped, 1 otherwise
excluded_path()
{
  local dir home
  dir="$(realpath "$1")"
  home="$(realpath "$HOME")"

  # Check if directory should be excluded
  for exclude in $EXCLUDE_DIRS; do
    # Check for parts of the directory name
    if [[ "$dir" == *"$exclude"* ]] || [[ "$dir" == "$exclude" ]]; then
      msg "Skipping excluded directory: $dir"
      return 0
    fi

    # Run only if home is not empty
    if [[ -n "$home" ]]; then
      # Remove home directory from path
      relative_dir="${dir/"$home"/}"

      # Check if we should exclude based on relative paths based on the home directory
      if [[ "$relative_dir" == *"$exclude"* ]] || [[ "$relative_dir" == "$exclude" ]]; then
        msg "Skipping excluded relative directory: $dir"
        return 0
      fi
    fi
  done

  # Check if it's a git repository
  if [[ ! -d "$dir/.git" ]]; then
    msg "Skipping non-git directory: $dir"
    return 0
  fi

  return 1
}

# Function to count git repositories
count_git_repos()
{
  local count=0
  for dir in */; do
    if ! excluded_path "$dir"; then
      ((count++))
    fi
  done
  echo $count
}

# Check for unmerged files or conflicts in a git repository
# Returns 0 if there are unmerged files, 1 otherwise
has_unmerged_files()
{
  git ls-files --unmerged | grep -q "^" \
    && return 0 || return 1
}

# Check for clean working directory
# Returns 0 if working directory is clean, 1 otherwise
is_repo_clean()
{
  git diff --quiet \
    && git diff --cached --quiet \
    && return 0 || return 1
}

# Function to clean up local branches that have been merged
# Returns the number of branches cleaned
cleanup_branches()
{
  local cleaned=0
  local current_branch output

  current_branch=$(git symbolic-ref --short HEAD 2> /dev/null)

  # Skip branch cleanup if we're not on a main branch
  if [[ ! "$current_branch" =~ ^(master|main|develop)$ ]]; then
    msg "Skipping branch cleanup: not on a main branch ($current_branch)"
    return 0
  fi

  # Get list of merged branches, excluding current branch, master, main, and develop
  output=$(git branch --merged | grep -v -E "^\*|master|main|develop" | sed 's/^[[:space:]]*//')

  if [[ -n "$output" ]]; then
    if [[ "$VERBOSE" -eq 1 ]]; then
      msg "Cleaning up merged branches in $(pwd):"
      echo "$output" | while read -r branch; do
        msg " - $branch"
      done
    fi

    # Delete branches
    for branch in $output; do
      if [[ -n "$branch" ]]; then
        if git branch -d "$branch" &> /dev/null; then
          ((cleaned++))
          log "INFO" "Deleted merged branch $branch in $(pwd)"
        else
          log "WARNING" "Failed to delete branch $branch in $(pwd)"
        fi
      fi
    done
  fi

  return $cleaned
}

# Function to update a git repository
# $1 - directory (string)
update_repo()
{
  local dir output exit_status git_args current_branch \
    remote_name cleaned_branches

  dir="$1"
  log "INFO" "Processing repository: $dir"

  # Increment the processed counter
  ((PROCESSED++))

  # Show progress before starting the operation
  show_progress "$PROCESSED" "$TOTAL" "${dir%/}"

  cd "$dir" 2> /dev/null || {
    log "ERROR" "Could not enter directory $dir"
    echo -e "\n${RED}Error: Could not enter directory $dir${NC}" >&2
    ((FAILED++))
    return 1
  }

  # If there are no remotes, skip
  if ! git remote -v &> /dev/null; then
    log "INFO" "Skipping directory with no remotes: $dir"
    msg "Skipping directory with no remotes: $dir"
    ((SKIPPED++))
    cd - > /dev/null || true
    return 1
  fi

  # Get current branch name
  current_branch=$(git symbolic-ref --short HEAD 2> /dev/null)
  if [[ -z "$current_branch" ]]; then
    log "INFO" "Skipping repository in detached HEAD state: $dir"
    msg "Skipping repository in detached HEAD state: $dir"
    ((SKIPPED++))
    cd - > /dev/null || true
    return 1
  fi

  # Check if current branch has tracking information
  eval "git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null" &> /dev/null || {
    log "INFO" "Skipping branch '$current_branch' without tracking info in $dir"
    msg "Skipping branch '$current_branch' without tracking info in $dir"
    ((SKIPPED++))
    cd - > /dev/null || true
    return 1
  }

  # Check if remote is accessible
  remote_name=$(git config --get branch."$current_branch".remote)
  if [[ -n "$remote_name" ]]; then
    if ! git ls-remote --exit-code "$remote_name" &> /dev/null; then
      log "WARNING" "Skipping repository with inaccessible remote '$remote_name': $dir"
      msg "Skipping repository with inaccessible remote: $dir"
      ((SKIPPED++))
      cd - > /dev/null || true
      return 1
    fi
  fi

  # Check for unmerged files before attempting pull
  if has_unmerged_files; then
    log "WARNING" "Skipping repository with unmerged files: $dir"
    msg "Skipping repository with unmerged files: $dir"
    ((UNMERGED++))
    cd - > /dev/null || true
    return 1
  fi

  # Configure Git arguments based on verbosity
  git_args="--rebase --autostash --prune"
  if [[ "$VERBOSE" -eq 0 ]]; then
    git_args="$git_args --quiet"
  fi

  # Disable Git hints and set other environment variables
  export GIT_MERGE_AUTOEDIT=no
  export GIT_CONFIG_COUNT=4
  export GIT_CONFIG_KEY_0="advice.skipHints"
  export GIT_CONFIG_VALUE_0="true"
  export GIT_CONFIG_KEY_1="advice.detachedHead"
  export GIT_CONFIG_VALUE_1="false"
  export GIT_CONFIG_KEY_2="advice.pushUpdateRejected"
  export GIT_CONFIG_VALUE_2="false"
  export GIT_CONFIG_KEY_3="advice.statusHints"
  export GIT_CONFIG_VALUE_3="false"

  # Capture the output of git pull
  if [[ "$VERBOSE" -eq 1 ]]; then
    # shellcheck disable=SC2086
    output=$(git pull $git_args 2>&1)
    exit_status=$?
    # In verbose mode, show the git output
    [[ "$QUIET" -eq 0 ]] && echo -e "\n$output\n"
    log "DEBUG" "Git pull output: $output"
  else
    # In non-verbose mode, suppress normal output but capture errors
    # shellcheck disable=SC2086
    output=$(git pull $git_args 2>&1) || {
      exit_status=$?
    }

    # If no error occurred, set exit_status to 0
    exit_status=${exit_status:-0}
  fi

  # Unset environment variables
  unset GIT_MERGE_AUTOEDIT GIT_CONFIG_COUNT \
    GIT_CONFIG_KEY_0 GIT_CONFIG_KEY_1 \
    GIT_CONFIG_KEY_2 GIT_CONFIG_KEY_3 \
    GIT_CONFIG_VALUE_0 GIT_CONFIG_VALUE_1 \
    GIT_CONFIG_VALUE_2 GIT_CONFIG_VALUE_3

  # Check for specific error conditions
  if echo "$output" | grep -q "Merge conflict"; then
    if [[ "$VERBOSE" -eq 1 ]]; then
      echo ""
      echo -e "${YELLOW}Merge conflict detected in $dir. Aborting update.${NC}" >&2
    fi
    log "WARNING" "Merge conflict detected in $dir. Aborting update."
    git rebase --abort &> /dev/null || git merge --abort &> /dev/null || true
    ((CONFLICTS++))
  elif echo "$output" | grep -q "unmerged files"; then
    if [[ "$VERBOSE" -eq 1 ]]; then
      echo ""
      echo -e "${YELLOW}Unmerged files detected in $dir. Aborting update.${NC}" >&2
    fi
    log "WARNING" "Unmerged files detected in $dir. Aborting update."
    ((UNMERGED++))
  elif echo "$output" | grep -q "untracked working tree files would be overwritten by merge"; then
    if [[ "$VERBOSE" -eq 1 ]]; then
      echo ""
      echo -e "${YELLOW}Untracked files would be overwritten in $dir. Aborting update.${NC}" >&2
    fi
    log "WARNING" "Untracked files would be overwritten in $dir. Aborting update."
    ((UNTRACKED++))
  elif [[ $exit_status -ne 0 ]]; then
    if [[ "$VERBOSE" -eq 1 || "$QUIET" -eq 0 ]]; then
      echo ""
      echo -e "${RED}Error updating $dir${NC}" >&2
      echo "$output" >&2
    fi
    log "ERROR" "Failed to update $dir: $output"
    ((FAILED++))
  else
    # Check if any changes were pulled
    if echo "$output" | grep -qE '(file changed|files changed|insertions|deletions)' \
      || ! echo "$output" | grep -q "Already up to date."; then
      log "INFO" "Repository updated with changes: $dir"
      ((UPDATED++))
    else
      log "INFO" "Repository already up to date: $dir"
    fi
    ((SUCCESS++))

    # Clean up branches if requested
    if [[ "$CLEANUP" -eq 1 ]]; then
      cleaned_branches=$(cleanup_branches)
      if [[ $cleaned_branches -gt 0 ]]; then
        ((BRANCHES_CLEANED += cleaned_branches))
        log "INFO" "Cleaned up $cleaned_branches merged branches in $dir"
      fi
    fi
  fi

  # Return to original directory
  cd - > /dev/null || true

  # Show progress after completion
  show_progress "$PROCESSED" "$TOTAL" "${dir%/} - Done"
}

# Main function to update all subfolder git repositories
main()
{
  local current_dir start_time end_time duration

  # Record start time
  start_time=$(date +%s)

  # Process command-line args before doing anything else
  process_args "$@"

  # Save current directory to return to it later
  current_dir=$(pwd)
  log "INFO" "Starting repository updates in $current_dir"

  # Count repositories and set TOTAL
  TOTAL=$(count_git_repos)
  print "Found $TOTAL git repositories to update"

  # Reset other counters
  PROCESSED=0
  SUCCESS=0
  FAILED=0
  CONFLICTS=0
  UPDATED=0
  SKIPPED=0
  UNTRACKED=0
  UNMERGED=0
  BRANCHES_CLEANED=0

  # Process each repository
  for dir in */; do
    # Skip if excluded
    if excluded_path "$dir"; then
      continue
    fi
    update_repo "$dir"
  done

  # Return to original directory
  cd "$current_dir" || true

  # Clear the progress line completely
  [[ "$QUIET" -eq 0 ]] && printf "\r%-${TERM_WIDTH}s\r" " "

  # Calculate duration
  end_time=$(date +%s)
  duration=$((end_time - start_time))
  minutes=$((duration / 60))
  seconds=$((duration % 60))

  # Format duration nicely
  if [[ $minutes -gt 0 ]]; then
    duration_str="${minutes}m ${seconds}s"
  else
    duration_str="${seconds}s"
  fi

  # Print summary unless quiet mode is enabled
  if [[ "$QUIET" -eq 0 ]]; then
    echo ""
    print "${GREEN}Summary: Updated $SUCCESS/$TOTAL repositories successfully in $duration_str.${NC}"
    print "${CYAN}Repositories with changes pulled: $UPDATED${NC}"

    if [[ $SKIPPED -gt 0 ]]; then
      print "${YELLOW}Skipped $SKIPPED repositories (no tracking branch or other issues).${NC}"
    fi

    if [[ $UNMERGED -gt 0 ]]; then
      print "${YELLOW}Skipped $UNMERGED repositories with unmerged files.${NC}"
    fi

    if [[ $UNTRACKED -gt 0 ]]; then
      print "${YELLOW}Skipped $UNTRACKED repositories with untracked files that would be overwritten.${NC}"
    fi

    if [[ $CONFLICTS -gt 0 ]]; then
      print "${YELLOW}Encountered merge conflicts in $CONFLICTS repositories.${NC}"
    fi

    if [[ $CLEANUP -eq 1 && $BRANCHES_CLEANED -gt 0 ]]; then
      print "${BLUE}Cleaned up $BRANCHES_CLEANED merged branches.${NC}"
    fi

    if [[ $FAILED -gt 0 ]]; then
      echo -e "${RED}Failed to update $FAILED repositories.${NC}" >&2
    else
      print "${GREEN}Done.${NC}"
    fi
  fi

  # Log final summary
  if [[ -n "$LOG_FILE" ]]; then
    log "INFO" "Completed in $duration_str"
    log "INFO" "Summary: $SUCCESS/$TOTAL repositories updated successfully"
    log "INFO" "Repositories with changes pulled: $UPDATED"
    log "INFO" "Skipped: $SKIPPED, Unmerged: $UNMERGED, Untracked: $UNTRACKED, Conflicts: $CONFLICTS, Failed: $FAILED"
    if [[ $CLEANUP -eq 1 ]]; then
      log "INFO" "Branches cleaned up: $BRANCHES_CLEANED"
    fi
  fi

  # Return appropriate exit code
  [[ $FAILED -gt 0 ]] && return 1 || return 0
}

# Call main with all arguments
main "$@"
