require 'rake'
require 'pp'

################################################################################################
# constants

ROOT_DIR = File.expand_path('.')
FINDER_DIR = '/System/Library/CoreServices/Finder.app'
FINDER_RESOURCES_DIR = File.join(FINDER_DIR, 'Contents/Resources')
PLUGIN_RESOURCES_DIR = File.join(ROOT_DIR, 'plugin')
INSTALLER_RESOURCES_DIR = File.join(ROOT_DIR, 'installer')
ENGLISH_LPROJ = File.join(PLUGIN_RESOURCES_DIR, 'en.lproj')
SHELL_SOURCES = [File.join(ROOT_DIR, '..', 'shell'), File.join(ROOT_DIR, '..', 'frameworks')]
TOTALFINDER_PLUGINS_SOURCES = File.join(ROOT_DIR, '..', 'plugins')

################################################################################################
# dependencies

begin
  require 'colored'
rescue LoadError
  raise 'You must "gem install colored" to use terminal colors'
end

################################################################################################
# helpers

def die(msg, status=1)
  puts "Error[#{status||$?}]: #{msg}".red
  exit status||$?
end

def sys(cmd)
  puts "> #{cmd}".yellow
  system(cmd)
end

################################################################################################
# routines

def write_file(filename, content)
  if ENV["dry"] then
    puts "in dry mode: would rewrite #{filename.blue} with content of size #{content.size}"
    return
  end
  
  File.open(filename, "w") do |f|
    f.write content
  end
end

def append_file(filename, content)
  if ENV["dry"] then
    puts "in dry mode: would append to #{filename.blue} content of size #{content.size}"
    return
  end
  
  File.open(filename, "a") do |f|
    f.write content
  end
end

def get_list_of_plugins(filter=nil)
  filter = "*" unless filter

  plugins = []
  Dir.glob(File.join(TOTALFINDER_PLUGINS_SOURCES, filter)) do |file|
    if File.directory?(file) and File.exists? File.join(file, File.basename(file)+".xcodeproj")  then
      plugins << File.basename(file)
    end
  end

  plugins.uniq
end

def ack(dir, glob, regexps)
  glob = File.join(dir, "**{,/*/**}", glob) # follow symlinks (http://stackoverflow.com/a/2724048/84283)
  set = []
  Dir.glob(glob) do |file|
    puts file if ENV["verbose"]
    content = File.read(file)
    regexps.each do |r|
      match = content.scan(r)
      set.concat match.flatten
    end
  end
  set.sort.uniq
end

def stitch_broken_strings(strings)
  # example of broken string
  # "This option will effectively disable the tabs module in TotalFinder. In effect the dual mode and visor functionality will be "
  #                              "disabled as well. This may be desired under Mavericks - use native Finder tabs "
  #                              "exclusively while keeping benefits of other TotalFinder features.\n\nFor this operation Finder has to be restarted!\nNote: "
  #                              "Prior restarting please finish all Finder tasks in progress (like copying or "
  #                              "moving files)."
  
  strings.map do |s|
    r = /([^\\])(".*?")/m
    s.gsub(r) do |x|
      $1
    end
  end
end

def extract_menuitems_strings(folder)
  dirs = Array(folder) # flexibility to pass multiple directories
  result = []
  dirs.each do |dir|
    result.concat ack(dir, "*.{cpp,mm,h}", [
      /\$M\s*\(\s*@\s*"(.*?)"\s*\)/m,
    ])
  end
  
  stitch_broken_strings(result.sort.uniq)
end

def extract_code_strings(folder)
  dirs = Array(folder) # flexibility to pass multiple directories

  result = []
  dirs.each do |dir|
    result.concat ack(dir, "*.{cpp,mm,m,h}", [
      /\$+\s*\(\s*@\s*"(.*?)"\s*\)/m,
      /\$NSLocalizedString\s*\(\s*@\s*"(.*?)"\s*[,\)]/m
    ])
  end
  
  stitch_broken_strings(result.sort.uniq)
end

def extract_ui_strings(folder, xibs)
  dirs = Array(folder) # flexibility to pass multiple directories
  result = []
  dirs.each do |dir|
    xibs.each do |xib|
      result.concat ack(dir, "#{xib}.xib", [
        /"\^(.*?[^\\])"/m
      ])
    end
  end
  result.sort.uniq
end

def parse_strings_file(filename)
  return [] unless File.exists? filename
  File.read(filename).lines
end

def update_strings(old_strings, new_keys, target)
  removed_count = 0
  count = 0

  # comment out existing strings as REMOVED or DUPLICIT if they are not present in new_strings
  known_keys = []
  strings = old_strings.map do |line|
    match = line =~ /^"(.*?)"/ # it is a valid key-definition line?
    next line unless match
    count += 1
    unless new_keys.include? $1 then
      line = "/* REMOVED #{line.strip} */\n" # ***
      removed_count += 1
    else
      if known_keys.include? $1 then
        line = "/* DUPLICIT #{line.strip} */\n" # ***
        removed_count += 1
      else
        known_keys << $1
      end
    end
    line
  end
  
  to_be_added = new_keys - known_keys

  write_file(target, strings.join(""))

  {
    "removed_count" => removed_count,
    "count" => count,
    "new_strings" => new_keys,
    "old_strings" => old_strings,
    "output" => strings,
    "to_be_added" => to_be_added.sort.uniq
  }
end

def update_english_strings(project, src_folder, xibs, additional_strings=[])
  target = File.join(ENGLISH_LPROJ, "#{project}.strings")
  code_strings = extract_code_strings(src_folder)
  ui_strings = extract_ui_strings(PLUGIN_RESOURCES_DIR, xibs)
  new_strings = code_strings.concat(ui_strings).concat(additional_strings)
  new_strings.sort.uniq!
  old_strings = parse_strings_file(target)
  
  res = update_strings(old_strings, new_strings, target)
  
  removed_count = res["removed_count"]
  strings = res["output"]
  count = res["count"]

  puts " #{"-#{removed_count}".red}/#{"#{count}".blue} in #{target}"
  
  res
end

def update_english_menuitems_strings(src_folder)
  target = File.join(ENGLISH_LPROJ, "MenuItems.strings")

  new_strings = extract_menuitems_strings(src_folder).map {|s| "MenuItem:#{s}"}
  old_strings = parse_strings_file(target)
  
  res = update_strings(old_strings, new_strings, target)
  
  removed_count = res["removed_count"]
  strings = res["output"]
  count = res["count"]

  puts " #{"-#{removed_count}".red}/#{"#{count}".blue} in #{target}"
  
  res
end

def categorize_xibs(plugins, dir=PLUGIN_RESOURCES_DIR)
  xibs = Hash.new
  
  # xib naming exceptions that don't follow conventions
  unconventional = {
    "SomeXibName" => "SomePluginName"
  } 
  
  Dir.glob(File.join(dir, "*.xib")) do |file|
    name = File.basename(file, ".xib")
    # does the name begin with some plugin name?
    plugin = plugins.find { |plugin| plugin==name or name.index(plugin) == 0  }
    if plugin.nil? and unconventional["name"] then
      plugin = unconventional["name"]
    end
    
    unless plugin.nil? then
      xibs[plugin] ||= []
      xibs[plugin] << name
      next
    end
    
    xibs["SHELL"] ||= []
    xibs["SHELL"] << name
  end
  
  xibs
end

def process_english_strings_in_plugins(plugins, xibs, dir=TOTALFINDER_PLUGINS_SOURCES)
  additions = Hash.new
  plugins.each do |plugin|
    plugin_dir = File.join(dir, plugin)
    next unless File.exists? plugin_dir
    next if xibs[plugin].nil? or xibs[plugin].size == 0 # an edge case for empty array

    additions[plugin] = update_english_strings(plugin, plugin_dir, xibs[plugin])["to_be_added"] # process just plugin xibs
  end
  additions
end

def process_english_strings_in_shell(xibs, duplicates, shell_dir=SHELL_SOURCES)
  update_english_strings("TotalFinder", shell_dir, xibs, duplicates) # process just shell xibs
end

def process_english_menuitems()
  update_english_menuitems_strings([TOTALFINDER_PLUGINS_SOURCES, SHELL_SOURCES].flatten)["to_be_added"]
end

def get_additions_duplicates(additions)
  all = []
  additions.each do |k, v|
    all.concat v
  end
  
  # count occurences and return only duplicities
  all.inject(Hash.new(0)) {|h,v| h[v] += 1; h}.reject{|k,v| v==1}.keys.sort.uniq
end

def insert_additions(list, target)
  return unless list.size>0
  
  strings = []
  strings << "\n"
  strings << "/* NEW STRINGS - TODO: SORT THEM IN OR CREATE A NEW SECTION */\n"
  
  list.each do |key|
    value = key.gsub("MenuItem:", "") # MenuItems special case
    strings <<  "\"#{key}\" = \"#{value}\";"
  end

  append_file(target, strings.join("\n"))
  
  puts " #{"+#{list.size}".yellow} in #{target}"
end

def inprint_strings(source, dest, shared_originals=[])
  strings = parse_strings_file(source)
  originals = []
  originals.concat shared_originals
  originals.concat parse_strings_file(dest)

  # transform lang back to english
  index = 0
  strings.map! do |line|
    index+=1
    next line unless (line.strip[0...1]=='"')

    line =~ /^\s*?(".*")\s*?=\s*?(".*")\s*?;\s*?/
    die "syntax error in #{source.blue}:#{index.to_s} [#{line}]" unless $1

    line = $1 + " = " + $1 + ";\n";

    line
  end

  # replace translations we already know from previsous version
  index = 0
  originals.each do |original|
    index+=1
    next unless (original.strip[0...1]=='"')

    original =~ /^\s*?(".*")\s*?=\s*?(".*")\s*?;(.*)$/
    needle = $1
    haystack = $2
    rest = $3
    die "syntax error in #{dest.blue}:#{index.to_s} [#{original}]" unless $1 and $2

    found = false
    strings.map! do |line|
      if (line.index needle) == 0 then
        line = needle + " = " + haystack + ";" + rest + "\n";
        found = true
      end

      line
    end
  end

  write_file(dest, strings.join(""))

  strings
end

def find_key(key, lines)
  lines.each do |line|
    next unless (line.strip[0...1]=='"')
    line =~ /^\s*?(".*")\s*?=\s*?(".*")\s*?;(.*)$/
    needle = $1
    haystack = $2
    die "syntax error in #{dest.blue}:#{index.to_s} [#{line}]" unless $1 and $2
    return haystack if needle==key
  end
  
  nil
end

def post_process_menuitems(dest, shared_originals=[])
  strings = parse_strings_file(dest)
  
  strings.map! do |line|
    next line unless (line.strip[0...1]=='"')

    line =~ /^\s*?(".*")\s*?=\s*?(".*")\s*?;(.*)$/
    die "syntax error in #{source.blue}:#{index.to_s} [#{line}]" unless $1

    key = $1
    val = $2
    rest = $3
    
    if (key==val) then
      # try to lookup exitsting val
      translated_val = find_key(key.gsub("MenuItem:", ""), shared_originals)
      unless translated_val.nil? then
        line = key + " = " + translated_val + ";" + rest + "\n";
      end
    end

    line
  end
  
  File.open(dest, "w") do |f|
    f << strings.join
  end

  strings
end

def propagate_english_to_cwd
  total = 0

  # TotalFinder.strings are master files, some strings may move between files
  all = parse_strings_file File.join(Dir.pwd, "TotalFinder.strings")
  Dir.glob(File.join(ENGLISH_LPROJ, "*.strings")) do |file|
    next if File.basename(file)=="TotalFinder.strings"
    all.concat parse_strings_file(File.join(Dir.pwd, File.basename(file)))
  end

  Dir.glob(File.join(ENGLISH_LPROJ, "*.strings")) do |file|
    puts "  #{File.basename(file)}".yellow
    total += inprint_strings(file, File.join(Dir.pwd, File.basename(file)), all).size
  end
  
  # post-process MenuItems.strings
  file = "MenuItems.strings"
  puts "  post processing #{file.yellow}"
  total += post_process_menuitems(File.join(Dir.pwd, file), all).size
  
  puts "  -> "+total.to_s.green+" strings processed"
end

def remove_missing_files_in_cwd
  files1 = Dir.glob(File.join(ENGLISH_LPROJ, "*")).map {|f| File.basename f }
  files2 = Dir.glob(File.join(Dir.pwd, "*")).map {|f| File.basename f }
  to_be_deleted = files2 - files1
  
  to_be_deleted.each do |file| 
    puts "deleting '#{file}'".red
    FileUtils.rm(file)
  end
end

def propagate_from_english_to_other_lprojs
  glob = ENV["to"] || "*.lproj"

  Dir.glob(File.join(PLUGIN_RESOURCES_DIR, glob)) do |dir|
    Dir.chdir dir do
      puts dir.blue
      propagate_english_to_cwd
      remove_missing_files_in_cwd
    end
  end
end

def create_localizations_for_project
  glob = ENV["to"] || "*.lproj"
  project = ENV["project"] || die("Project name not defined")

  Dir.glob(File.join(PLUGIN_RESOURCES_DIR, glob)) do |dir|
    Dir.chdir dir do
      write_file(File.join(dir, project + ".strings"), "/* no strings */")
    end
  end
end

def exec_cmd_in_lprojs(cmd)
  glob = ENV["to"] || "*.lproj"

  Dir.glob(File.join(PLUGIN_RESOURCES_DIR, glob)) do |dir|
    puts dir.blue
    Dir.chdir dir do
      sys(cmd)
    end
  end
end

def validate_strings_file path
  lines = parse_strings_file(path)

  in_multi_line_comment = false
  counter = 0
  count = lines.size
  lines.each do |line|
    counter += 1
    if in_multi_line_comment and line =~ /.*\*\/\w*$/
      in_multi_line_comment = false
      next
    end
    next if in_multi_line_comment
    line = line.gsub(/\r\n?/, "") + "\n"
    next if line =~ /^".*?"\s*=\s*".*?";\s*$/
    next if line =~ /^".*?"\s*=\s*".*?";\s*\/\*.*?\*\/$/
    next if line =~ /^".*?"\s*=\s*".*?";\s*\/\/.*?$/
    next if line =~ /^\/\*.*?\*\/$/
    next if line =~ /^\s*$/
    if line =~ /^\/\*[^\*]*/ then
      in_multi_line_comment = true
      next
    end

    puts "line ##{counter}: unrecognized pattern".red+" (fix rakefile if this is a valid pattern)"
    puts line
    puts "mate -l #{counter} \"#{path}\"".yellow
    return false
  end

  true
end

def validate_strings_files
  begin
    require 'cmess/guess_encoding'
  rescue LoadError
    die 'You must "gem install cmess" to use character encoding detection'
  end

  glob = ENV["to"] || "*.lproj"

  counter = 0
  failed = 0
  warnings = 0

  known_files = []
  Dir.glob(File.join(PLUGIN_RESOURCES_DIR, "en.lproj", "*")) do |path|
    known_files << File.basename(path)
  end

  Dir.glob(File.join(PLUGIN_RESOURCES_DIR, "*.lproj")) do |dir|
    unrecognized_files = []
    missing_files = known_files.dup

    Dir.glob(File.join(dir, "*")) do |path|
      file = File.basename(path)

      if missing_files.include?(file) then
        missing_files.delete(file)
      else
        unrecognized_files << file
      end
    end

    if (!missing_files.empty? or !unrecognized_files.empty?) then
      warnings += 1

      puts "in " + dir.blue + ":"
      if (!missing_files.empty?) then
        puts "  missing files: " + missing_files.join(", ")
      end
      if (!unrecognized_files.empty?) then
        puts "  unrecognized files: " + unrecognized_files.join(", ")
      end
    end
  end

  Dir.glob(File.join(PLUGIN_RESOURCES_DIR, glob, "*.strings")) do |path|
    counter += 1
    ok = 1
    input   = File.read path
    charset = "ASCII"
    if input.strip.size>0 then
      charset = CMess::GuessEncoding::Automatic.guess(input)
      ok = ((validate_strings_file path) and (charset=="ASCII" or charset=="UTF-8"))
    end
    puts charset.magenta+" "+path.blue+" "+"ok".yellow if ok
    puts charset.magenta+" "+path.blue+" "+"failed".red unless ok
    failed +=1 unless ok
  end

  all = []
  Dir.glob(File.join(PLUGIN_RESOURCES_DIR, "en.lproj", "*.strings")) do |file|
    all.concat parse_strings_file(file)
  end

  list = []
  all.each do |original|
    next unless (original.strip[0...1]=='"')

    original =~ /^\s*?"(.*)"\s*?=/
    list << $1
  end

  dups = list.inject(Hash.new(0)) {|h,v| h[v] += 1; h}.reject{|k,v| v==1}.keys

  if dups.size>0 then
    puts
    puts "found duplicate keys:".red
    dups.each { |x| puts "  #{x}" }
    puts
    puts "solution:".yellow + " shared keys should be placed in TotalFinder.strings".blue
    puts
  end

  puts "-----------------------------------"
  puts "checked "+"#{counter} files".magenta+" and "+(failed>0 ? ("#{failed} failed".red) : ("all is ok".yellow)) + (warnings>0?(" [#{warnings} warnings]".green):(""))
end

def stub_installer_lprojs
  glob = "*.lproj"

  english_source = File.join(INSTALLER_RESOURCES_DIR, "en.lproj")
  die("need #{english_source}!") unless File.exists?(english_source)

  Dir.glob(File.join(PLUGIN_RESOURCES_DIR, glob)) do |dir|
    name = File.basename(dir)
    next if name == "en.lproj"
    full_path = File.join(INSTALLER_RESOURCES_DIR, name)
    next if File.exists?(full_path) # already have it
    puts "Creating stub " + full_path.blue
    sys("cp -r \"#{english_source}\" \"#{full_path}\"")
  end
end

################################################################################################
# tasks

desc "switch /Applications/TotalFinder.app into dev mode"
task :dev do
  sys("./bin/dev.sh")
end

desc "switch /Applications/TotalFinder.app into non-dev mode"
task :undev do
  sys("./bin/undev.sh")
end

desc "restart Finder.app"
task :restart do
  sys("./bin/restart.sh")
end

desc "normalize Finder.app so it contains all our language folders (run with sudo)"
task :normalize do
  lprojs = File.join(PLUGIN_RESOURCES_DIR, '*.lproj')
  Dir.glob(lprojs) do |folder|
    dir = File.join(FINDER_RESOURCES_DIR, File.basename(folder))
    if File.exists? dir then
      puts dir.blue + " exists".yellow
    else
      if !sys("mkdir -p \"#{dir}\"") then
        die("Unable to create a folder. Hint: you should run this as sudo rake normalize")
      end
      puts dir.blue + " created".green
    end
  end
end

desc "cherrypicks strings from sources and applies missing strings to en.lproj"
task :cherrypick do
  die "install ack 2.0+ | for example via homebrew:> brew install ack" if `which ack`==""
  die "upgrade your ack to 2.0+ | for example via homebrew:> brew install ack" unless `ack --version`=~/ack 2/

  plugins = get_list_of_plugins()
  xibs = categorize_xibs(plugins)

  puts "XIBs:".blue
  pp xibs

  puts
  puts "Processing string files:".yellow
  
  # additons is a hash containing an array of added translation keys for each plugin
  additions = process_english_strings_in_plugins(plugins, xibs)
  duplicates = get_additions_duplicates(additions)
  res = process_english_strings_in_shell(xibs["SHELL"], duplicates) # duplicates will be moved into shell
  shell_additions = res["to_be_added"] 
  shell_new_strings = res["new_strings"]
  
  menuitems_additions = process_english_menuitems()

  # insert additions to plugins
  plugins.each do |plugin|
    next unless additions[plugin]
    target = File.join(ENGLISH_LPROJ, "#{plugin}.strings")
    list = additions[plugin] - duplicates - shell_new_strings
    insert_additions(list, target)
  end

  # insert additions to shell
  target = File.join(ENGLISH_LPROJ, "TotalFinder.strings")
  insert_additions(shell_additions, target)

  # insert additions to menu items
  target = File.join(ENGLISH_LPROJ, "MenuItems.strings")
  insert_additions(menuitems_additions, target)
  
  unhandled_duplicates = duplicates - shell_new_strings
  if unhandled_duplicates.size>0 then
    puts 
    puts "Found these shared keys in plugins and shell, we moved them to TotalFinder.strings:".yellow
    puts unhandled_duplicates.join(", ").magenta
  end
end

desc "propagates structure of en.lproj to all other language folders while keeping already translated strings"
task :propagate do
  propagate_from_english_to_other_lprojs
end

desc "make stub lproj folders for installer, creates all which exist in plugin"
task :stub do
  stub_installer_lprojs
end

desc "exec command in all lproj folders"
task :exec do
  exec_cmd_in_lprojs(ENV["cmd"] || "ls")
end

desc "validates all strings files and checks them for syntax errors"
task :validate do
  validate_strings_files
end

desc "validates all strings files and checks them for syntax errors"
task :create_localization do
  create_localizations_for_project
end

task :default => :restart