#!/usr/bin/env ruby
#
# cask_namer
#
# todo:
#
# detect Cask files which differ only by the placement of hyphens.
#

###
### dependencies
###

require 'pathname'
require 'open3'

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

###
### configurable constants
###

EXPANDED_NUMBERS = {
                     '0' => 'zero',
                     '1' => 'one',
                     '2' => 'two',
                     '3' => 'three',
                     '4' => 'four',
                     '5' => 'five',
                     '6' => 'six',
                     '7' => 'seven',
                     '8' => 'eight',
                     '9' => 'nine',
                   }

EXPANDED_SYMBOLS = {
                    '+' => 'plus',
                   }

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 name.
APP_EXCEPTION_PATS = {
                      %r{\Aiterm\Z}i               => 'iterm2',
                      %r{\Apgadmin3\Z}i            => 'pgadmin3',
                      %r{\Ax48\Z}i                 => 'x48',
                      %r{\Avitamin-r[\s\d\.]*\Z}i  => 'vitamin-r',
                      %r{\Aimagealpha\Z}i          => 'imagealpha',
                      %r{\Aplayonmac\Z}i           => 'playonmac',
                      %r{\Akismac\Z}i              => 'kismac',
                      %r{\Avoicemac\Z}i            => 'voicemac',
                      %r{\Acleanmymac[\s\d\.]*\Z}i => 'cleanmymac',
                      %r{\Abitcoin-?qt\Z}i         => 'bitcoin-core',
                     }

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

# 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
                        %r{\s+}i,

                        # generic terms
                        %r{\bapp}i,
                        # idea, but never discussed
                        # %r{\blauncher}i,

                        # "mac", "for mac", "for OS X".
                        %r{\b(?:for)?[\s-]*mac(?:intosh)?}i,
                        %r{\b(?:for)?[\s-]*os[\s-]*x}i,

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

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

                        # localizations
                        %r{en\s*-\s*us}i,

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

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

###
### classes
###

class AppName < String
  def self.remove_trailing_pat
    @@remove_trailing_pat ||= %r{(?<=.)(?:#{REMOVE_TRAILING_PATS.join('|')})\Z}i
  end

  def self.preserve_trailing_pat
    @@preserve_trailing_pat ||= %r{(?:#{PRESERVE_TRAILING_PATS.join('|')})\Z}i
  end

  def self.after_interior_version_pat
    @@after_interior_version_pat ||= %r{(?:#{AFTER_INTERIOR_VERSION_PATS.join('|')})}i
  end

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

    # check Info.plist CFBundleDisplayName
    bundle_name = Open3.popen3(*%w[
                                   /usr/libexec/PlistBuddy -c
                                  ],
                               'Print CFBundleDisplayName',
                               Pathname.new(self).join('Contents', 'Info.plist').to_s
                               ) do |stdin, stdout, stderr|
      begin
        stdout.gets.force_encoding("UTF-8").chomp
      rescue
      end
    end
    return AppName.new(bundle_name) if bundle_name and bundle_name.ascii_only?

    # check Info.plist CFBundleName
    bundle_name = Open3.popen3(*%w[
                                   /usr/libexec/PlistBuddy -c
                                  ],
                               'Print CFBundleName',
                               Pathname.new(self).join('Contents', 'Info.plist').to_s
                               ) do |stdin, stdout, stderr|
      begin
        stdout.gets.force_encoding("UTF-8").chomp
      rescue
      end
    end
    return AppName.new(bundle_name) if bundle_name and bundle_name.ascii_only?

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

    # check Info.plist CFBundleExecutable
    bundle_name = Open3.popen3(*%w[
                                   /usr/libexec/PlistBuddy -c
                                  ],
                               'Print CFBundleExecutable',
                               Pathname.new(self).join('Contents', 'Info.plist').to_s
                               ) do |stdin, stdout, stderr|
      begin
        stdout.gets.force_encoding("UTF-8").chomp
      rescue
      end
    end
    return AppName.new(bundle_name) if bundle_name and bundle_name.ascii_only?

    self
  end

  def basename
    if Pathname.new(self).exist? then
      AppName.new(Pathname.new(self).basename.to_s)
    else
      self
    end
  end

  def remove_extension
    self.sub(/\.app\Z/i, '')
  end

  def decompose_to_ascii
    # crudely (and incorrectly) decompose extended latin characters to ASCII
    return self if self.ascii_only?
    return self unless self.respond_to?(:mb_chars)
    AppName.new(self.mb_chars.normalize(:kd).each_char.select(&:ascii_only?).join)
  end

  def hardcoded_exception
    APP_EXCEPTION_PATS.each do |regexp, exception|
      if regexp.match(self) then
        return AppName.new(exception)
      end
    end
    return nil
  end

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

  def insert_vertical_tabs_for_snake_case
    self.gsub(/_/, "\v")
  end

  def clean_up_vertical_tabs
    self.gsub(/\v/, '')
  end

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

  def remove_trailing_strings_and_versions
    app_name = self.insert_vertical_tabs_for_camel_case
                   .insert_vertical_tabs_for_snake_case
    while   self.class.remove_trailing_pat.match(app_name)   and
        not self.class.preserve_trailing_pat.match(app_name)
      app_name.sub!(self.class.remove_trailing_pat, '')
    end
    app_name.remove_interior_versions!
    app_name.clean_up_vertical_tabs
  end

  def canonical
    return @canonical if @canonical
    @canonical = self.english_from_app_bundle
                     .basename
                     .decompose_to_ascii
                     .remove_extension
    name_exception = @canonical.hardcoded_exception
    @canonical = name_exception ? name_exception : @canonical.remove_trailing_strings_and_versions
  end
end

class CaskFileName < String
  def spaces_to_hyphens
    self.gsub(/ +/, '-')
  end

  def delete_invalid_chars
    self.gsub(/[^a-z0-9-]+/, '')
  end

  def collapse_multiple_hyphens
    self.gsub(/--+/, '-')
  end

  def delete_leading_hyphens
    self.gsub(/^--+/, '')
  end

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

  def spell_out_leading_numbers
    cask_file_name = self
    EXPANDED_NUMBERS.each do |k, v|
      cask_file_name.sub!(/^#{k}/, v)
    end
    cask_file_name
  end

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

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

  def remove_extension
    self.sub(/#{escaped_cask_file_extension}\Z/i, '')
  end

  def from_canonical_name
    return @from_canonical_name if @from_canonical_name
    @from_canonical_name = if APP_EXCEPTION_PATS.rassoc(self.remove_extension)
      self.remove_extension
    else
      self.remove_extension
          .downcase
          .spell_out_symbols
          .spaces_to_hyphens
          .delete_invalid_chars
          .collapse_multiple_hyphens
          .delete_leading_hyphens
          .delete_hyphens_before_numbers
          .spell_out_leading_numbers
    end
    raise "Could not determine Cask name" unless @from_canonical_name.length > 0
    @from_canonical_name.add_extension
  end
end

class CaskClassName < String
  def basename
    if Pathname.new(self).exist?
      CaskClassName.new(Pathname.new(self).basename.to_s)
    else
      self
    end
  end

  def remove_extension
    self.sub(/#{escaped_cask_file_extension}\Z/i, '')
  end

  def hyphens_to_camel_case
    self.split('-').map(&:capitalize).join
  end

  def from_cask_name
    # or from filename
    self.basename.remove_extension.hyphens_to_camel_case
  end
end

###
### methods
###

def project_root
  Dir.chdir File.dirname(File.expand_path(__FILE__))
  @git_root ||= Open3.popen3(*%w[
                                 git rev-parse --show-toplevel
                                ]) do |stdin, stdout, stderr|
    begin
      Pathname.new(stdout.gets.chomp)
    rescue
      raise "could not find project root"
    end
  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 canonical_name
  @canonical_name ||= AppName.new("#{ARGV.first}".force_encoding("UTF-8")).canonical
end

def cask_file_name
  @cask_file_name ||= CaskFileName.new(canonical_name).from_canonical_name
end

def cask_name
  @cask_name ||= cask_file_name.remove_extension
end

def class_name
  @class_name ||= CaskClassName.new(cask_name).from_cask_name
end

def warnings
  return @warnings if @warnings
  @warnings = []
  unless APP_EXCEPTION_PATS.rassoc(cask_name)
    if %r{\d}.match(cask_name)
      @warnings.push "WARNING: '#{cask_name}' contains digits. Digits which are version numbers should be removed."
    end
  end
  filename = project_root.join('Casks', cask_file_name)
  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 canonical App name: #{canonical_name}" if $debug
  puts "Proposed Cask name:          #{cask_name}"
  puts "Proposed file name:          #{cask_file_name}"
  puts "First Line of Cask:          class #{class_name} < Cask"
  if warnings.length > 0
    STDERR.puts "\n"
    STDERR.puts warnings
    STDERR.puts "\n"
    exit 1
  end
end

###
### main
###

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

Given an Application name or a path to an Application,
propose a Cask name, filename and class name.

With -debug, provide the internal Canonical App Name.

EOS

if ARGV.first =~ %r{^-+h(elp)?$}i
  puts usage
  exit 0
end

if ARGV.first =~ %r{^-+debug?$}i
  $debug = 1
  ARGV.shift
end

unless ARGV.length == 1
  puts usage
  exit 1
end

report
