#!/usr/bin/ruby.ruby3.4
# frozen_string_literal: true

# This script runs RuboCop only on modified lines in git diff
# Based on: https://gist.github.com/skanev/9d4bec97d5a6825eaaf6
# Adapted for GitLab CI

require 'rubocop'
require 'json'

# Display help message and exit
def show_help
  warn 'Usage: rubocop-git [--local|--uncommitted|--branch|--against=REFSPEC] [--courage]'
  warn ''
  warn 'Options:'
  warn '  --local          Check changes to be pushed (diff against upstream)'
  warn '  --uncommitted    Check uncommitted changes (diff against HEAD)'
  warn '  --branch         Check changes in current branch (diff against master)'
  warn '  --against=REF    Check changes against specific git reference'
  warn '  --courage        Check entire modified files, not just changed lines'
  warn '  --help, -h       Show this help message'
  warn ''
  warn 'GitLab CI example:'
  warn '  bin/rubocop-git --against=origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME'
  exit 0
end

# Parse command line arguments
options = {
  against: nil,
  branch: false,
  local: false,
  uncommitted: false,
  courage: false
}

ARGV.each do |arg|
  case arg
  when '--help', '-h'
    show_help
  when '--local'
    options[:local] = true
  when '--uncommitted'
    options[:uncommitted] = true
  when '--branch'
    options[:branch] = true
  when '--courage'
    options[:courage] = true
  when /^--against=(.+)$/
    options[:against] = Regexp.last_match(1)
  else
    warn "Unknown option: #{arg}"
    exit 1
  end
end

# Determine git diff command based on options
diff_command = if options[:local]
                 'git diff @{upstream}...HEAD'
               elsif options[:uncommitted]
                 'git diff HEAD'
               elsif options[:branch]
                 'git diff master...HEAD'
               elsif options[:against]
                 "git diff #{options[:against]}...HEAD"
               else
                 warn 'Usage: rubocop-git [--local|--uncommitted|--branch|--against=REFSPEC] [--courage]'
                 warn ''
                 warn 'Options:'
                 warn '  --local          Check changes to be pushed (diff against upstream)'
                 warn '  --uncommitted    Check uncommitted changes (diff against HEAD)'
                 warn '  --branch         Check changes in current branch (diff against master)'
                 warn '  --against=REF    Check changes against specific git reference'
                 warn '  --courage        Check entire modified files, not just changed lines'
                 warn ''
                 warn 'GitLab CI example:'
                 warn '  bin/rubocop-git --against=origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME'
                 exit 1
               end

# Build a hash of files and their changed line numbers
files_and_lines = Hash.new { |hash, key| hash[key] = Set.new }

# Parse git diff output
diff_output = `#{diff_command} --diff-filter=AMT`
current_file = nil
current_line_number = 0

diff_output.each_line do |line|
  case line
  when %r{^\+\+\+ b/(.+)$}
    # New file in diff
    current_file = Regexp.last_match(1)
    files_and_lines[current_file] # Initialize the set
    current_line_number = 0
  when /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/
    # Hunk header - extract starting line number in new file
    current_line_number = Regexp.last_match(1).to_i
  when /^\+(?!\+)/
    # Line addition (starts with + but not +++ which is file marker)
    files_and_lines[current_file] << current_line_number if current_file
    current_line_number += 1
  when /^ /
    # Context line (unchanged)
    current_line_number += 1
  when /^-/
    # Line deletion - don't increment line number in new file
    next
  end
end

# Files excluded per .rubocop.yml AllCops.Exclude
# Note: db/migrate/ is NOT excluded - we want to lint new migrations
EXCLUDED_PATTERNS = [
  %r{^db/schema\.rb$}
].freeze

# Filter to only Ruby files (respecting exclusions)
ruby_files = files_and_lines.keys.select do |file|
  next false unless File.exist?(file)
  next false if EXCLUDED_PATTERNS.any? { |pattern| file.match?(pattern) }
  next true if file.end_with?('.rb') || file.end_with?('.rake')
  # For bin/ files, check if they're Ruby scripts (exclude bash scripts)
  next false unless file.start_with?('bin/')

  first_line = File.open(file, &:readline)
  first_line.start_with?('#!/usr/bin/ruby.ruby3.4') || first_line.start_with?('#!/usr/bin/ruby')
rescue StandardError
  false
end

# Check a single file for RuboCop offenses on modified lines
def check_file(file, changed_lines, courage)
  cli = RuboCop::CLI.new

  if courage
    # Check entire file
    cli.run([file])
  else
    # Check only modified lines
    check_modified_lines(cli, file, changed_lines)
  end
end

# Check only modified lines in a file
def check_modified_lines(cli, file, changed_lines)
  require 'stringio'
  old_stdout = $stdout
  $stdout = StringIO.new

  cli.run(['--format', 'json', file])

  output = $stdout.string
  $stdout = old_stdout

  # Parse JSON output and filter offenses
  results = JSON.parse(output)
  file_result = results['files'].first

  return 0 unless file_result && file_result['offenses']

  # Filter offenses to only those on changed lines
  filtered_offenses = file_result['offenses'].select do |offense|
    changed_lines.include?(offense['location']['line'])
  end

  return 0 unless filtered_offenses.any?

  # Display filtered offenses
  filtered_offenses.each do |offense|
    line = offense['location']['line']
    col = offense['location']['column']
    severity = offense['severity']
    cop = offense['cop_name']
    message = offense['message']
    puts "#{file}:#{line}:#{col}: #{severity[0].upcase}: #{cop}: #{message}"
  end
  1
rescue JSON::ParserError => e
  warn "Failed to parse RuboCop output: #{e.message}"
  2
end

if ruby_files.empty?
  puts 'No Ruby files changed'
  exit 0
end

# Run RuboCop on each file, checking only modified lines
exit_code = 0

ruby_files.each do |file|
  changed_lines = files_and_lines[file].to_a.sort
  result = check_file(file, changed_lines, options[:courage])
  exit_code = result if result != 0 && exit_code.zero?
end

exit exit_code
