#!/usr/bin/env ruby
# frozen_string_literal: true

require "json"
require "open3"
require "optparse"
require "rubygems"
require "tempfile"
require "time"

DEFAULT_REPO = "chenrui333/homebrew-tap"
SUPERSEDED_LABEL = "superseded"
TITLE_PATTERN = /\A(?<formula>[A-Za-z0-9][A-Za-z0-9._+@-]*) (?<version>[0-9][A-Za-z0-9._+:-]*)\z/
SKIP_LABELS = ["new formula", "new cask"].freeze
TAP_ROOT = File.expand_path("..", __dir__)

options = {
  apply: false,
  closed_days: 30,
  closed_limit: 1000,
  formulas: [],
  limit: 1000,
  repo: ENV.fetch("GH_REPO", DEFAULT_REPO),
}

parser = OptionParser.new do |opts|
  opts.banner = "Usage: brew close-superseded-prs [options]"

  opts.on("--apply", "Comment, label, and close superseded PRs") do
    options[:apply] = true
  end

  opts.on("--dry-run", "Print planned closures without changing GitHub state (default)") do
    options[:apply] = false
  end

  opts.on("--formula NAME", "Only inspect one formula or cask (can be repeated)") do |formula|
    options[:formulas] << formula
  end

  opts.on("--cask NAME", "Only inspect one cask (can be repeated)") do |cask|
    options[:formulas] << cask
  end

  opts.on("--limit N", Integer, "Maximum open PRs to inspect (default: 1000)") do |limit|
    options[:limit] = limit
  end

  opts.on("--closed-limit N", Integer, "Maximum closed PRs to inspect as superseding updates (default: 1000)") do |limit|
    options[:closed_limit] = limit
  end

  opts.on("--closed-days N", Integer, "Only inspect PRs closed in the last N days (default: 30; 0 disables closed PR history)") do |days|
    options[:closed_days] = days
  end

  opts.on("--repo OWNER/REPO", "GitHub repository (default: #{DEFAULT_REPO})") do |repo|
    options[:repo] = repo
  end

  opts.on("-h", "--help", "Show this help") do
    puts opts
    exit
  end
end

parser.parse!

def odie(message)
  warn "Error: #{message}"
  exit 1
end

def command_env
  env = {
    "GH_NO_UPDATE_NOTIFIER" => "1",
    "GH_PROMPT_DISABLED" => "1",
  }

  token = ENV["GH_TOKEN"] || ENV["GITHUB_TOKEN"] || ENV["HOMEBREW_GITHUB_API_TOKEN"]
  env["GH_TOKEN"] = token if token && !token.empty?
  env
end

def brew_env
  command_env.merge(
    "HOMEBREW_NO_AUTO_UPDATE" => "1",
    "HOMEBREW_NO_INSTALL_FROM_API" => "1",
  )
end

def capture3(*command, env: command_env)
  Open3.capture3(env, *command)
end

def capture!(*command, env: command_env)
  stdout, stderr, status = capture3(*command, env: env)
  return stdout if status.success?

  odie("#{command.join(" ")} failed:\n#{stderr}")
end

def gh_json(*args)
  JSON.parse(capture!("gh", *args))
end

def package_info(package, root = TAP_ROOT)
  candidates = [
    [:formula, File.join(root, "Formula", package[0], "#{package}.rb")],
    [:formula, File.join(root, "Formula", "#{package}.rb")],
    [:cask, File.join(root, "Casks", package[0], "#{package}.rb")],
    [:cask, File.join(root, "Casks", "#{package}.rb")],
  ]
  type, path = candidates.find { |_, candidate| File.file?(candidate) }
  return unless path

  { type: type, path: path }
end

def tap_name(repo)
  owner, name = repo.split("/", 2)
  return repo unless owner && name

  "#{owner}/#{name.delete_prefix("homebrew-")}"
end

def tap_root(repo)
  @tap_roots ||= {}
  return @tap_roots.fetch(repo) if @tap_roots.key?(repo)

  stdout, stderr, status = capture3("brew", "--repo", tap_name(repo), env: brew_env)
  unless status.success?
    warn "Warning: could not locate #{tap_name(repo)} tap: #{stderr.strip}"
    return @tap_roots[repo] = TAP_ROOT
  end

  @tap_roots[repo] = stdout.strip
end

def package_version(package, repo)
  info = package_info(package, tap_root(repo)) || package_info(package)
  return unless info

  type = info.fetch(:type)
  path = info.fetch(:path)
  type_flag = type == :formula ? "--formula" : "--cask"

  stdout, stderr, status = capture3("brew", "info", "--json=v2", type_flag, path, env: brew_env)
  unless status.success?
    warn "Warning: could not determine current #{package} version: brew info failed:\n#{stderr}"
    return
  end

  metadata = JSON.parse(stdout)
  if type == :formula
    metadata.fetch("formulae").first.fetch("versions").fetch("stable")
  else
    metadata.fetch("casks").first.fetch("version")
  end
rescue StandardError => e
  warn "Warning: could not determine current #{package} version: #{e.message}"
  nil
end

def version_for_compare(version)
  Gem::Version.new(version.to_s.strip.sub(/\Av/i, "").tr("_-", "."))
end

def compare_versions(left, right)
  version_for_compare(left) <=> version_for_compare(right)
rescue ArgumentError
  left.to_s <=> right.to_s
end

def created_at(pr)
  Time.parse(pr.fetch("createdAt"))
end

def closed_at(pr)
  Time.parse(pr.fetch("closedAt"))
rescue ArgumentError, TypeError
  nil
end

def pr_sort_key(pr)
  [created_at(pr), pr.fetch("number")]
end

def bump_pr(title)
  match = TITLE_PATTERN.match(title)
  return unless match

  {
    formula: match[:formula],
    version: match[:version],
  }
end

def pr_labels(pr)
  pr.fetch("labels").map { |label| label.fetch("name") }
end

def bump_prs(prs, formula_filter)
  prs.each_with_object([]) do |pr, bump_prs|
    parsed = bump_pr(pr.fetch("title"))
    next unless parsed
    next if formula_filter.any? && !formula_filter[parsed[:formula]]
    next if (pr_labels(pr) & SKIP_LABELS).any?
    next unless package_info(parsed[:formula])

    bump_prs << pr.merge(parsed)
  end
end

def superseded_body(repo, pr)
  body = "Superseded by #{repo}##{pr.fetch("number")}"
  state = pr["state"]
  if state && state != "OPEN"
    body += pr["mergedAt"].to_s.empty? ? " (closed)" : " (merged)"
  end
  body
end

def recent_closed_prs(prs, closed_since)
  prs.select do |pr|
    closed_at = closed_at(pr)
    closed_at && closed_at >= closed_since
  end
end

def ensure_superseded_label(repo)
  labels = gh_json("label", "list", "--repo", repo, "--limit", "1000", "--json", "name")
  return if labels.any? { |label| label.fetch("name") == SUPERSEDED_LABEL }

  capture!(
    "gh", "label", "create", SUPERSEDED_LABEL,
    "--repo", repo,
    "--color", "ededed",
    "--description", "Closed because a newer package update supersedes this PR"
  )
end

def close_pr(repo, number, body)
  Tempfile.create(["pr-#{number}-superseded", ".md"]) do |file|
    file.write("#{body}\n")
    file.close
    capture!("gh", "pr", "comment", number.to_s, "--repo", repo, "--body-file", file.path)
  end

  capture!("gh", "pr", "edit", number.to_s, "--repo", repo, "--add-label", SUPERSEDED_LABEL)
  capture!("gh", "pr", "close", number.to_s, "--repo", repo)
end

formula_filter = {}
options[:formulas].each do |formula|
  formula_filter[formula.sub(%r{\Achenrui333/tap/}, "")] = true
end
odie("--limit must be non-negative") if options[:limit].negative?
odie("--closed-limit must be non-negative") if options[:closed_limit].negative?
odie("--closed-days must be non-negative") if options[:closed_days].negative?

open_prs = gh_json(
  "pr", "list",
  "--repo", options[:repo],
  "--state", "open",
  "--limit", options[:limit].to_s,
  "--json", "number,title,labels,createdAt,state,mergedAt"
)

closed_since = Time.now.utc - (options[:closed_days] * 86_400)
closed_prs = if options[:closed_limit].positive? && options[:closed_days].positive?
  recent_closed_prs(gh_json(
    "pr", "list",
    "--repo", options[:repo],
    "--state", "closed",
    "--limit", options[:closed_limit].to_s,
    "--search", "closed:>=#{closed_since.strftime("%Y-%m-%d")}",
    "--json", "number,title,labels,createdAt,closedAt,state,mergedAt"
  ), closed_since)
else
  []
end

open_bump_prs = bump_prs(open_prs, formula_filter)
all_bump_prs = open_bump_prs + bump_prs(closed_prs, formula_filter)
all_bump_prs_by_formula = all_bump_prs.group_by { |pr| pr.fetch(:formula) }

closures = []
open_bump_prs.group_by { |pr| pr.fetch(:formula) }.each do |formula, formula_prs|
  current_version = package_version(formula, options[:repo])
  pending_prs = []

  formula_prs.each do |pr|
    if current_version && compare_versions(current_version, pr.fetch(:version)) >= 0
      closures << {
        number: pr.fetch("number"),
        formula: formula,
        version: pr.fetch(:version),
        body: "Superseded by current main, which has #{formula} #{current_version}.",
      }
    else
      pending_prs << pr
    end
  end

  next if pending_prs.empty?

  # Keep the most recently opened bump PR, even if it is already merged/closed
  # or intentionally rolls back a bad higher-version bump.
  keeper = all_bump_prs_by_formula.fetch(formula).max_by { |pr| pr_sort_key(pr) }

  pending_prs.each do |pr|
    next if pr.fetch("number") == keeper.fetch("number")
    next if (pr_sort_key(keeper) <=> pr_sort_key(pr)).negative?

    closures << {
      number: pr.fetch("number"),
      formula: formula,
      version: pr.fetch(:version),
      body: superseded_body(options[:repo], keeper),
    }
  end
end

closures.uniq! { |closure| closure.fetch(:number) }

if closures.empty?
  puts "No superseded package PRs found."
  exit
end

verb = options[:apply] ? "Closing" : "Would close"
closures.sort_by { |closure| closure.fetch(:number) }.each do |closure|
  puts "#{verb} ##{closure.fetch(:number)} #{closure.fetch(:formula)} #{closure.fetch(:version)}: #{closure.fetch(:body)}"
end

if options[:apply]
  ensure_superseded_label(options[:repo])
  closures.each do |closure|
    close_pr(options[:repo], closure.fetch(:number), closure.fetch(:body))
  end
  puts "Closed #{closures.length} superseded package PR(s)."
else
  puts "Dry-run only. Re-run with --apply to close these PRs."
end
