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

# rubocop:disable Style/TopLevelMethodDefinition

#
# generate_cask_token
#
# todo:
#
# detect Cask files which differ only by the placement of hyphens.
#
# merge entirely into "brew create" command
#

###
### dependencies
###

require "pathname"
require "open3"

begin
  # not available by default
  require "active_support/inflector"
rescue LoadError
  nil
end

###
### configurable constants
###

EXPANDED_SYMBOLS = {
  "+" => "plus",
  "@" => "at",
}.freeze

CASK_FILE_EXTENSION = ".rb"

# Hardcode App names that cannot be transformed automatically.
# Example: in "x48.app", "x48" is not a version number.
# The value in the hash should be a valid Cask token.
APP_EXCEPTION_PATS = {
  # looks like a trailing version, but is not.
  /\Aiterm\Z/i              => "iterm2",
  /\Aiterm2\Z/i             => "iterm2",
  /\Apgadmin3\Z/i           => "pgadmin3",
  /\Ax48\Z/i                => "x48",
  /\Avitamin-r[\s\d.]*\Z/i  => "vitamin-r",
  /\Aimagealpha\Z/i         => "imagealpha",
  # upstream is in the midst of changing branding
  /\Abitcoin-?qt\Z/i        => "bitcoin-core",
  # "mac" cannot be separated from the name because it is in an English phrase
  /\Aplayonmac\Z/i          => "playonmac",
  /\Acleanmymac[\s\d.]*\Z/i => "cleanmymac",
  # arguably we should not have kept these two exceptions
  /\Akismac\Z/i             => "kismac",
  /\Avoicemac\Z/i           => "voicemac",
}.freeze

# Preserve trailing patterns on App names that could be mistaken
# for version numbers, etc
PRESERVE_TRAILING_PATS = [
  /id3/i,
  /mp3/i,
  /3[\s-]*d/i,
  /diff3/i,
  /\A[^\d]+\+\Z/i,
].freeze

# The code that employs these patterns against App names
# - hacks a \b (word-break) between CamelCase and snake_case transitions
# - anchors the pattern to end-of-string
# - applies the patterns repeatedly until there is no match
REMOVE_TRAILING_PATS = [
  # spaces
  /\s+/i,

  # generic terms
  /\bapp/i,
  /\b(?:quick[\s-]*)?launcher/i,

  # "mac", "for mac", "for OS X", "macOS", "for macOS".
  /\b(?:for)?[\s-]*mac(?:intosh|OS)?/i,
  /\b(?:for)?[\s-]*os[\s-]*x/i,

  # hardware designations such as "for x86", "32-bit", "ppc"
  /(?:\bfor\s*)?x.?86/i,
  /(?:\bfor\s*)?\bppc/i,
  /(?:\bfor\s*)?\d+.?bits?/i,

  # frameworks
  /\b(?:for)?[\s-]*(?:oracle|apple|sun)*[\s-]*(?:jvm|java|jre)/i,
  /\bgtk/i,
  /\bqt/i,
  /\bwx/i,
  /\bcocoa/i,

  # localizations
  /en\s*-\s*us/i,

  # version numbers
  /[^a-z0-9]+/i,
  /\b(?:version|alpha|beta|gamma|release|release.?candidate)(?:[\s.\d-]*\d[\s.\d-]*)?/i,
  /\b(?:v|ver|vsn|r|rc)[\s.\d-]*\d[\s.\d-]*/i,
  /\d+(?:[a-z.]\d+)*/i,
  /\b\d+\s*[a-z]/i,
  /\d+\s*[a-c]/i, # constrained to a-c b/c of false positives
].freeze

# Patterns which are permitted (undisturbed) following an interior version number
AFTER_INTERIOR_VERSION_PATS = [
  /ce/i,
  /pro/i,
  /professional/i,
  /client/i,
  /server/i,
  /host/i,
  /viewer/i,
  /launcher/i,
  /installer/i,
].freeze

###
### classes
###

class AppName
  def initialize(string)
    @string = string
  end

  def self.remove_trailing_pat
    @@remove_trailing_pat ||= /(?<=.)(?:#{REMOVE_TRAILING_PATS.join("|")})\Z/i # rubocop:disable Style/ClassVars
  end

  def self.preserve_trailing_pat
    @@preserve_trailing_pat ||= /(?:#{PRESERVE_TRAILING_PATS.join("|")})\Z/i # rubocop:disable Style/ClassVars
  end

  def self.after_interior_version_pat
    @@after_interior_version_pat ||= /(?:#{AFTER_INTERIOR_VERSION_PATS.join("|")})/i # rubocop:disable Style/ClassVars
  end

  def english_from_app_bundle
    return self if @string.ascii_only?
    return self unless File.exist?(self)

    # check Info.plist CFBundleDisplayName
    bundle_name = Open3.popen3("/usr/libexec/PlistBuddy", "-c",
                               "Print CFBundleDisplayName",
                               Pathname.new(self).join("Contents", "Info.plist").to_s) do |_stdin, stdout, _stderr|
      stdout.gets.force_encoding("UTF-8").chomp
    rescue
      nil
    end
    return self.class.new(bundle_name) if bundle_name&.ascii_only?

    # check Info.plist CFBundleName
    bundle_name = Open3.popen3("/usr/libexec/PlistBuddy", "-c",
                               "Print CFBundleName",
                               Pathname.new(self).join("Contents", "Info.plist").to_s) do |_stdin, stdout, _stderr|
      stdout.gets.force_encoding("UTF-8").chomp
    rescue
      nil
    end
    return self.class.new(bundle_name) if bundle_name&.ascii_only?

    # check localization strings
    local_strings_file = Pathname.new(self).join("Contents", "Resources", "en.lproj", "InfoPlist.strings")
    unless local_strings_file.exist?
      local_strings_file = Pathname.new(self).join("Contents", "Resources", "English.lproj", "InfoPlist.strings")
    end
    if local_strings_file.exist?
      bundle_name = File.open(local_strings_file, "r:UTF-16LE:UTF-8") do |fh|
        /\ACFBundle(?:Display)?Name\s*=\s*"(.*)";\Z/.match(
          fh.readlines.grep(/^CFBundle(?:Display)?Name\s*=\s*/).first,
        ) do |match|
          match.captures.first
        end
      end
      return self.class.new(bundle_name) if bundle_name&.ascii_only?
    end

    # check Info.plist CFBundleExecutable
    bundle_name = Open3.popen3("/usr/libexec/PlistBuddy", "-c",
                               "Print CFBundleExecutable",
                               Pathname.new(self).join("Contents", "Info.plist").to_s) do |_stdin, stdout, _stderr|
      stdout.gets.force_encoding("UTF-8").chomp
    rescue
      nil
    end
    return self.class.new(bundle_name) if bundle_name&.ascii_only?

    self
  end

  def basename
    if Pathname.new(@string).exist?
      self.class.new(Pathname.new(@string).basename.to_s)
    else
      self
    end
  end

  def remove_extension
    self.class.new(@string.sub(/\.app\Z/i, ""))
  end

  def decompose_to_ascii
    # crudely (and incorrectly) decompose extended latin characters to ASCII
    return self if @string.ascii_only?
    return self unless @string.respond_to?(:mb_chars)

    self.class.new(@string.mb_chars.normalize(:kd).each_char.select(&:ascii_only?).join)
  end

  def hardcoded_exception
    APP_EXCEPTION_PATS.each do |regexp, exception|
      return self.class.new(exception) if regexp.match(@string)
    end
    nil
  end

  def insert_vertical_tabs_for_camel_case
    app_name = @string.dup
    trailing = Regexp.last_match(1) if app_name.sub!(/(#{self.class.preserve_trailing_pat})\Z/i, "")
    app_name.gsub!(/([^A-Z])([A-Z])/, "\\1\v\\2")
    app_name.sub!(/\Z/, trailing) if trailing
    self.class.new(app_name)
  end

  def insert_vertical_tabs_for_snake_case
    self.class.new(@string.tr("_", "\v"))
  end

  def clean_up_vertical_tabs
    self.class.new(@string.delete("\v"))
  end

  def remove_interior_versions
    # done separately from REMOVE_TRAILING_PATS because this
    # requires a substitution with a backreference
    app_name = @string
               .sub(/(?<=.)[.\d]+(#{self.class.after_interior_version_pat})\Z/i, '\1')
               .sub(/(?<=.)[\s.\d-]*\d[\s.\d-]*(#{self.class.after_interior_version_pat})\Z/i, '-\1')
    self.class.new(app_name)
  end

  def remove_trailing_strings_and_versions
    app_name = insert_vertical_tabs_for_camel_case
               .insert_vertical_tabs_for_snake_case.to_s
    while self.class.remove_trailing_pat.match(app_name) &&
          !self.class.preserve_trailing_pat.match(app_name)
      app_name.sub!(self.class.remove_trailing_pat, "")
    end
    self.class.new(app_name).remove_interior_versions.clean_up_vertical_tabs
  end

  def simplified
    return @simplified if @simplified

    @simplified = english_from_app_bundle
                  .basename
                  .decompose_to_ascii
                  .remove_extension
    @simplified = @simplified.hardcoded_exception || @simplified.remove_trailing_strings_and_versions
    @simplified
  end

  def to_s
    @string
  end
end

class CaskFileName
  def initialize(string)
    @string = string
  end

  def spaces_to_hyphens
    self.class.new(@string.gsub(/ +/, "-"))
  end

  def delete_invalid_chars
    self.class.new(@string.gsub(/[^a-z0-9-]+/, ""))
  end

  def collapse_multiple_hyphens
    self.class.new(@string.gsub(/--+/, "-"))
  end

  def delete_leading_hyphens
    self.class.new(@string.gsub(/^--+/, ""))
  end

  def delete_hyphens_before_numbers
    self.class.new(@string.gsub(/-([0-9])/, '\1'))
  end

  def spell_out_symbols
    cask_file_name = @string.dup
    EXPANDED_SYMBOLS.each do |k, v|
      cask_file_name.gsub!(k, " #{v} ")
    end
    cask_file_name.sub!(/ +\Z/, "")
    self.class.new(cask_file_name)
  end

  def add_extension
    self.class.new(@string.sub(/(?:#{escaped_cask_file_extension})?\Z/i, CASK_FILE_EXTENSION))
  end

  def remove_extension
    self.class.new(@string.sub(/#{escaped_cask_file_extension}\Z/i, ""))
  end

  def downcase
    self.class.new(@string.downcase)
  end

  def from_simplified_app_name
    return @from_simplified_app_name if @from_simplified_app_name

    @from_simplified_app_name = if APP_EXCEPTION_PATS.rassoc(remove_extension)
      remove_extension
    else
      remove_extension
        .downcase
        .spell_out_symbols
        .spaces_to_hyphens
        .delete_invalid_chars
        .collapse_multiple_hyphens
        .delete_leading_hyphens
        .delete_hyphens_before_numbers
    end
    raise "Could not determine Simplified App name" if @from_simplified_app_name.to_s.empty?

    @from_simplified_app_name.add_extension
  end

  def to_s
    @string
  end
end

###
### methods
###

def project_root
  Dir.chdir File.dirname(File.expand_path(__FILE__))
  @git_root ||= Open3.popen3("git", "rev-parse", "--show-toplevel") do |_stdin, stdout, _stderr|
    Pathname.new(stdout.gets.chomp)
  rescue
    raise "could not find project root"
  end
  raise "could not find project root" unless @git_root.exist?

  @git_root
end

def escaped_cask_file_extension
  @escaped_cask_file_extension ||= Regexp.escape(CASK_FILE_EXTENSION)
end

def simplified_app_name
  @simplified_app_name ||= AppName.new(ARGV.first.dup.force_encoding("UTF-8")).simplified
end

def cask_file_name
  @cask_file_name ||= CaskFileName.new(simplified_app_name.to_s).from_simplified_app_name
end

def cask_token
  @cask_token ||= cask_file_name.remove_extension.to_s
end

def warnings
  return @warnings if @warnings

  @warnings = []
  if !APP_EXCEPTION_PATS.rassoc(cask_token) && /\d/.match?(cask_token)
    @warnings.push "WARNING: '#{cask_token}' contains digits. Digits which are version numbers should be removed."
  end
  filename = project_root.join("Casks", cask_file_name.to_s[0], cask_file_name.to_s)
  if filename.exist?
    @warnings.push(
      "WARNING: the file '#{filename}' already exists. Prepend the vendor name if this is not a duplicate.",
    )
  end
  @warnings
end

def report
  puts "Proposed Simplified App name: #{simplified_app_name}" if $debug # rubocop:disable Style/GlobalVars
  puts "Proposed token:               #{cask_token}"
  puts "Proposed file name:           #{cask_file_name}"
  puts "Cask Header Line:             cask \"#{cask_token}\" do"
  return if warnings.empty?

  $stderr.puts "\n"
  $stderr.puts warnings
  $stderr.puts "\n"
  exit 1
end

###
### main
###

usage = <<~EOS
  Usage: generate_cask_token [ -debug ] <application.app>

  Given an Application name or a path to an Application, propose a
  Cask token, filename, and header line.

  With -debug, also provide the internal "Simplified App Name".

EOS

if /^-+h(elp)?$/i.match?(ARGV.first)
  puts usage
  exit 0
end

if /^-+debug?$/i.match?(ARGV.first)
  $debug = 1 # rubocop:disable Style/GlobalVars
  ARGV.shift
end

if ARGV.length != 1
  puts usage
  exit 1
end

report

# rubocop:enable Style/TopLevelMethodDefinition
