# frozen_string_literal: true

require "timeout"
require "cask/cask_loader"
require "system_command"
require "utils/github/actions"

# Launches a cask's apps so they create their initial support files, quits
# them again, then compares `brew generate-zap` output with the cask's
# existing `zap` stanza and reports any uncovered paths to maintainers.
module ZapCheck
  extend SystemCommand::Mixin

  FNMATCH_FLAGS = (File::FNM_CASEFOLD | File::FNM_EXTGLOB).freeze
  LAUNCH_TIMEOUT = 15
  SETTLE_SECONDS = 10
  QUIT_TIMEOUT = 10
  GENERATE_ZAP_TIMEOUT = 300

  def self.run(cask_path)
    $stdout.sync = true

    cask = Cask::CaskLoader.load(cask_path)
    apps = cask.artifacts.grep(Cask::Artifact::App).map(&:target).select(&:directory?)
    exercise(apps)

    result = generate_zap(cask_path)
    if result.nil?
      report cask_path, "Unable to check `#{cask.token}`:",
             "`brew generate-zap` timed out after #{GENERATE_ZAP_TIMEOUT} seconds."
      return
    end

    unless result.success?
      report cask_path, "Unable to check `#{cask.token}` with `brew generate-zap`:",
             Tty.strip_ansi(result.stderr.presence || result.stdout)
      return
    end

    stanza = Tty.strip_ansi(result.stdout)[/^zap .*/m]
    generated_paths = stanza&.scan(/"([^"]+)"/)&.flatten
    if generated_paths.blank?
      puts "brew generate-zap found no leftover files for #{cask.token}."
      return
    end

    puts "Stanza generated by brew generate-zap:", stanza, ""

    missing_paths = reject_covered(cask, generated_paths)
    if missing_paths.empty?
      puts "All #{generated_paths.count} generated paths are covered by the existing zap stanza."
      return
    end

    report cask_path,
           "`brew generate-zap` found paths not covered by the current `zap` stanza of `#{cask.token}`:",
           missing_paths.join("\n")
  end

  def self.generate_zap(cask_path)
    system_command ENV.fetch("HOMEBREW_BREW_FILE"),
                   args:         ["generate-zap", cask_path],
                   print_stderr: false,
                   timeout:      GENERATE_ZAP_TIMEOUT
  rescue Timeout::Error
    nil
  end

  def self.exercise(apps)
    return if apps.empty?

    apps.each do |app|
      # Gatekeeper blocks `open` on quarantined apps and the quarantine
      # attribute has already been verified during `brew install`.
      system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", app.to_s], print_stderr: false
      launch app

      unless wait_until(LAUNCH_TIMEOUT) { running?(app) }
        puts "Warning: #{app} was not running after #{LAUNCH_TIMEOUT} seconds."
      end
    end

    sleep SETTLE_SECONDS

    quit apps
  end

  def self.launch(app)
    result = system_command "/usr/bin/open", args: ["-g", app.to_s], print_stderr: false, timeout: LAUNCH_TIMEOUT
    return if result.success?

    puts "Warning: Failed to open #{app} (exit status: #{result.exit_status}):"
    puts result.merged_output
  rescue Timeout::Error
    puts "Warning: Timed out opening #{app} after #{LAUNCH_TIMEOUT} seconds."
  end

  # AppleScript quit events are blocked by TCC on CI, so signal the app's
  # processes directly. Quitting also prompts `cfprefsd` to write the app's
  # preferences to disk, where `brew generate-zap` can find them.
  def self.quit(apps)
    apps.each do |app|
      signal("TERM", app)
      next if wait_until(QUIT_TIMEOUT) { !running?(app) }

      puts "Warning: #{app} did not quit after SIGTERM, sending SIGKILL."
      signal("KILL", app)
    end

    sleep 3
  end

  def self.signal(name, app)
    system_command "/usr/bin/pkill", args: ["-#{name}", "-f", process_pattern(app)], print_stderr: false
  end

  def self.running?(app)
    system_command("/usr/bin/pgrep", args: ["-f", process_pattern(app)], print_stderr: false).success?
  end

  def self.process_pattern(app)
    "^#{Regexp.escape("#{app}/")}"
  end

  def self.wait_until(timeout)
    deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
    loop do
      return true if yield
      return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline

      sleep 0.5
    end
  end

  def self.reject_covered(cask, generated_paths)
    zap_directives = cask.artifacts.grep(Cask::Artifact::Zap).map(&:directives)
    deletable_paths = zap_directives.flat_map { |directives| directives.values_at(:trash, :delete) }
                                    .flatten.compact.map(&:to_s)
    # `rmdir` only removes empty directories, so it covers a generated path
    # itself but not anything beneath it.
    rmdir_paths = zap_directives.flat_map { |directives| directives[:rmdir] }
                                .flatten.compact.map(&:to_s)

    generated_paths.reject do |generated|
      deletable_paths.any? { |existing| covers?(existing, generated) || descendant?(generated, of: existing) } ||
        rmdir_paths.any? { |existing| covers?(existing, generated) }
    end
  end

  def self.covers?(existing, generated)
    existing == generated ||
      File.fnmatch?(existing, generated, FNMATCH_FLAGS) ||
      # Generated paths may themselves contain wildcards (e.g. collapsed
      # UUIDs), which an existing literal entry can never fnmatch.
      File.fnmatch?(generated, existing, FNMATCH_FLAGS)
  end

  def self.descendant?(path, of:)
    path.downcase.start_with?("#{of.downcase}/")
  end

  def self.report(cask_path, intro, body)
    puts GitHub::Actions::Annotation.new(:warning, "#{intro}\n#{body}",
                                         file: cask_path, title: "Possible missing zap paths")

    return unless (summary_path = ENV.fetch("GITHUB_STEP_SUMMARY", nil))

    File.open(summary_path, "a") do |file|
      file.puts <<~MARKDOWN
        ## Possible missing zap paths

        #{intro}

        ```text
        #{body}
        ```
      MARKDOWN
    end
  end
end
