require 'yaml'
require 'json'
require 'zlib'

module Esvg
  class SVG
    attr_accessor :svgs, :last_read, :svg_symbols

    CONFIG = {
      filename: 'svgs',
      class: 'svg-symbol',
      namespace: 'svg',
      core: true,
      namespace_before: true,
      optimize: false,
      compress: false,
      throttle_read: 4,
      flatten: [],
      alias: {}
    }

    CONFIG_RAILS = {
      source: "app/assets/svgs",
      assets: "app/assets/javascripts",
      build: "public/assets",
      temp: "tmp"
    }

    def initialize(options={})
      config(options)

      @modules = {}
      @last_read = nil

      read_cache
      read_files
    end

    def config(options={})
      @config ||= begin
        paths = [options[:config_file], 'config/esvg.yml', 'esvg.yml'].compact

        config = CONFIG.dup

        if Esvg.rails? || options[:rails]
          config.merge!(CONFIG_RAILS)
        end

        if path = paths.select{ |p| File.exist?(p)}.first
          config.merge!(symbolize_keys(YAML.load(File.read(path) || {})))
        end

        config.merge!(options)

        config[:filename] = File.basename(config[:filename], '.*')

        config[:pwd]      = File.expand_path Dir.pwd
        config[:source]   = File.expand_path config[:source] || config[:pwd]
        config[:build]    = File.expand_path config[:build]  || config[:pwd]
        config[:assets]   = File.expand_path config[:assets] || config[:pwd]

        config[:temp]     = config[:pwd] if config[:temp].nil?
        config[:temp]     = File.expand_path File.join(config[:temp], '.esvg-cache')

        config[:aliases] = load_aliases(config[:alias])
        config[:flatten] = [config[:flatten]].flatten.map { |dir| File.join(dir, '/') }.join('|')

        config
      end
    end

    def read_files
      if !@last_read.nil? && (Time.now.to_i - @last_read) < config[:throttle_read]
        return
      end

      # Get a list of svg files and modification times
      #
      find_files
      write_cache

      @last_read = Time.now.to_i

      puts "Read #{svgs.size} files from #{config[:source]}" if config[:print]

      if svgs.empty? && config[:print]
        puts "No svgs found at #{config[:source]}"
      end
    end

    def find_files
      files = Dir[File.join(config[:source], '**/*.svg')].uniq.sort

      # Remove deleted files from svg cache
      (svgs.keys - file_keys(files)).each { |f| svgs.delete(f) }

      dirs = {}

      files.each do |path|

        mtime = File.mtime(path).to_i
        key = file_key path
        dkey = dir_key path

        # Use cache if possible
        if svgs[key].nil? || svgs[key][:last_modified] != mtime
          svgs[key] = process_file(path, mtime, key)
        end

        dirs[dkey]           ||= {}
        (dirs[dkey][:files]  ||= []) << key

        if dirs[dkey][:last_modified].nil? || dirs[dkey][:last_modified] < mtime
          dirs[dkey][:last_modified] = mtime 
        end
      end

      dirs = sort(dirs)

      # Remove deleted directories from svg_symbols cache
      (svg_symbols.keys - dirs.keys).each {|dir| svg_symbols.delete(dir) }

      dirs.each do |dir, data|

        # overwrite cache if
        if svg_symbols[dir].nil?                                   || # No cache for this dir yet
          svg_symbols[dir][:last_modified] != data[:last_modified] || # New or updated file
          svg_symbols[dir][:optimized] != optimize?                || # Cache is unoptimized
          svg_symbols[dir][:files] != data[:files]                    # Changed files

          symbols    = data[:files].map { |f| svgs[f][:content] }.join
          attributes = data[:files].map { |f| svgs[f][:attr] }

          svg_symbols[dir] = data.merge({
            name: dir,
            symbols: optimize(symbols, attributes),
            optimized: optimize?,
            version: config[:version] || Digest::MD5.hexdigest(symbols),
            asset: File.basename(dir).start_with?('_')
          })

        end

        svg_symbols.keys.each do |dir|
          svg_symbols[dir][:path] = write_path(dir)
        end
      end
      
      @svg_symbols = sort(@svg_symbols)
      @svgs = sort(@svgs)
    end

    def read_cache
      @svgs        = YAML.load(read_tmp '.svgs') || {}
      @svg_symbols = YAML.load(read_tmp '.svg_symbols') || {}
    end

    def write_cache
      return if production?

      write_tmp '.svgs', sort(@svgs).to_yaml
      write_tmp '.svg_symbols', sort(@svg_symbols).to_yaml

    end

    def sort(hash)
      sorted = {}
      hash.sort.each do |h|
        sorted[h.first] = h.last
      end
      sorted
    end

    def embed_script(key=nil)
      if script = js(key)
        "<script>#{script}</script>"
      else
        ''
      end
    end

    def build_paths(keys=nil)
      build_files(keys).map { |s| File.basename(s[:path]) }
    end

    def build_files(keys=nil)
      valid_keys(keys).reject do |k|
        svg_symbols[k][:asset]
      end.map { |k| svg_symbols[k] }
    end

    def asset_files(keys=nil)
      valid_keys(keys).select do |k|
        svg_symbols[k][:asset]
      end.map { |k| svg_symbols[k] }
    end

    def process_file(file, mtime, name)
      content   = File.read(file)
      classname = classname(name)
      size_attr = dimensions(content)

      svg = {
        name: name,
        use: %Q{<use xlink:href="##{classname}"/>},
        last_modified: mtime,
        attr: { class: classname }.merge(dimensions(content))
      }
      # Add attributes
      svg[:content] = prep_svg(content, svg[:attr])

      svg
    end

    def use(file, options={})
      if name = exist?(file, options[:fallback])
        svg = svgs[name]

        if options[:color]
          options[:style] ||= ''
          options[:style] += "color:#{options[:color]};#{options[:style]}"
        end

        attr = {
          fill:   options[:fill],
          style:  options[:style],
          viewbox: svg[:attr][:viewbox],
          classname: [config[:class], svg[:attr][:class], options[:class]].compact.join(' ')
        }

        # If user doesn't pass a size or set scale: true
        if !(options[:width] || options[:height] || options[:scale])

          # default to svg dimensions
          attr[:width]  = svg[:attr][:width]
          attr[:height] = svg[:attr][:height]
        else

          # Add sizes (nil options will be stripped)
          attr[:width]  = options[:width]
          attr[:height] = options[:height]
        end

        use = %Q{<svg #{attributes(attr)}>#{svg[:use]}#{title(options)}#{desc(options)}</svg>}

        if Esvg.rails?
          use.html_safe
        else
          use
        end
      else
        if production?
          return ''
        else
          raise "no svg named '#{get_alias(file)}' exists at #{config[:source]}"
        end
      end
    end

    alias :svg_icon :use

    def dimensions(input)
      viewbox = input.scan(/<svg.+(viewBox=["'](.+?)["'])/).flatten.last
      coords  = viewbox.split(' ')

      {
        viewbox: viewbox,
        width: coords[2].to_i - coords[0].to_i,
        height: coords[3].to_i - coords[1].to_i
      }
    end

    def attributes(hash)
      att = []
      hash.each do |key, value|
        att << %Q{#{key}="#{value}"} unless value.nil?
      end
      att.join(' ')
    end

    def exist?(name, fallback=nil)
      name = get_alias dasherize(name)

      if svgs[name].nil?
        exist?(fallback) if fallback
      else
        name
      end

    end

    alias_method :exists?, :exist?

    def classname(name)
      if config[:namespace_before]
        dasherize "#{config[:namespace]}-#{name}"
      else
        dasherize "#{name}-#{config[:namespace]}"
      end
    end

    def dasherize(input)
      input.gsub(/[\W,_]/, '-').sub(/^-/,'').gsub(/-{2,}/, '-')
    end

    def title(options)
      if options[:title]
        "<title>#{options[:title]}</title>"
      else
        ''
      end
    end

    def desc(options)
      if options[:desc]
        "<desc>#{options[:desc]}</desc>"
      else
        ''
      end
    end

    def version(key)
      svg_symbols[key][:version]
    end

    def build
      paths = write_files svg_symbols.values

      if config[:core]
        path = File.join config[:assets], "_esvg.js"
        write_file(path, js_core)
        paths << path
      end

      paths
    end

    def write_files(files)
      paths = []

      files.each do |file|
        if file[:asset] || !File.exist?(file[:path])
          write_file(file[:path], js(file[:name]))
          puts "Writing #{file[:path]}" if config[:print]
          paths << file[:path]

          if !file[:asset] && gz = compress(file[:path])
            puts "Writing #{gz}" if config[:print]
            paths << gz
          end
        end
      end

      paths
    end

    def symbols(keys)
      symbols = valid_keys(keys).map { |key|
        svg_symbols[key][:symbols]
      }.join.gsub(/\n/,'')

      %Q{<svg id="esvg-#{key_id(keys)}" version="1.1" style="height:0;position:absolute">#{symbols}</svg>}
    end

    def js(key)
      keys = valid_keys(key)
      return if keys.empty?

      script key_id(keys), symbols(keys).gsub('/n','').gsub("'"){"\\'"}
    end

    def script(id, symbols)
      %Q{(function(){

  function embed() {
    if (!document.querySelector('#esvg-#{id}')) {
      document.querySelector('body').insertAdjacentHTML('afterbegin', '#{symbols}')
    }
  }

  // If DOM is already ready, embed SVGs
  if (document.readyState == 'interactive') { embed() }

  // Handle Turbolinks page change events
  if ( window.Turbolinks ) {
    document.addEventListener("turbolinks:load", function(event) { embed() })
  }

  // Handle standard DOM ready events
  document.addEventListener("DOMContentLoaded", function(event) { embed() })
})()}
    end

    def js_core
      %Q{(function(){
  var names

  function attr( source, name ){
    if (typeof source == 'object')
      return name+'="'+source.getAttribute(name)+'" '
    else
      return name+'="'+source+'" ' }

  function dasherize( input ) {
    return input.replace(/[\\W,_]/g, '-').replace(/-{2,}/g, '-')
  }

  function svgName( name ) {
    #{if config[:namespace_before]
      %Q{return "#{config[:namespace]}-"+dasherize( name )}
    else
      %Q{return dasherize( name )+"-#{config[:namespace]}"}
    end}
  }

  function use( name, options ) {
    options = options || {}
    var id = dasherize( svgName( name ) )
    var symbol = svgs()[id]

    if ( symbol ) {
      var svg = document.createRange().createContextualFragment( '<svg><use xlink:href="#'+id+'"/></svg>' ).firstChild;
      svg.setAttribute( 'class', '#{config[:class]} '+id+' '+( options.classname || '' ).trim() )
      svg.setAttribute( 'viewBox', symbol.getAttribute( 'viewBox' ) )

      if ( !( options.width || options.height || options.scale ) ) {

        svg.setAttribute('width',  symbol.getAttribute('width'))
        svg.setAttribute('height', symbol.getAttribute('height'))

      } else {

        if ( options.width )  svg.setAttribute( 'width',  options.width )
        if ( options.height ) svg.setAttribute( 'height', options.height )
      }

      return svg
    } else {
      console.error('Cannot find "'+name+'" svg symbol. Ensure that svg scripts are loaded')
    }
  }

  function svgs(){
    if ( !names ) {
      names = {}
      for( var symbol of document.querySelectorAll( 'svg[id^=esvg] symbol' ) ) {
        names[symbol.id] = symbol
      }
    }
    return names
  }

  var esvg = {
    svgs: svgs,
    use: use
  }

  // Handle Turbolinks page change events
  if ( window.Turbolinks ) {
    document.addEventListener( "turbolinks:load", function( event ) { names = null; esvg.svgs() })
  }

  if( typeof( module ) != 'undefined' ) { module.exports = esvg }
  else window.esvg = esvg

})()}
    end

    private

    def dir_key(path)
      dir = File.dirname(flatten_path(path))

      # Flattened paths which should be treated as assets will use '_' as their dir key
      if dir == '.' && ( sub_path(path).start_with?('_') || config[:filename].start_with?('_') )
        '_'
      else
        dir
      end
    end

    def sub_path(path)
      path.sub("#{config[:source]}/",'')
    end

    def flatten_path(path)
      sub_path(path).sub(Regexp.new(config[:flatten]), '')
    end

    def file_key(path)
      dasherize flatten_path(path).sub('.svg', '')
    end

    def file_keys(paths)
      paths.flatten.map { |p| file_key(p) }
    end

    def write_path(key)
      name = if key == '_'  # Root level asset file
        "_#{config[:filename]}".sub(/_+/, '_')
      elsif key == '.'      # Root level build file
        config[:filename]
      else
        "#{key}"
      end

      # Is it an asset, or a build file
      if name.start_with?('_')
        File.join config[:assets], "#{name}.js"
      else
        File.join config[:build], "#{name}-#{version(key)}.js"
      end
    end

    def prep_svg(content, attr)
      content = content.gsub(/<?.+\?>/,'').gsub(/<!.+?>/,'')  # Get rid of doctypes and comments
         .gsub(/\n/, '')                                      # Remove endlines
         .gsub(/\s{2,}/, ' ')                                 # Remove whitespace
         .gsub(/>\s+</, '><')                                 # Remove whitespace between tags
         .gsub(/\s?fill="(#0{3,6}|black|rgba?\(0,0,0\))"/,'') # Strip black fill
         .gsub(/style="([^"]*?)fill:(.+?);/m, 'fill="\2" style="\1')                   # Make fill a property instead of a style
         .gsub(/style="([^"]*?)fill-opacity:(.+?);/m, 'fill-opacity="\2" style="\1')   # Move fill-opacity a property instead of a style

      sub_def_ids(content, attr[:class])
    end

    # Scans <def> blocks for IDs
    # If urls(#id) are used, ensure these IDs are unique to this file
    # Only replace IDs if urls exist to avoid replacing defs
    # used in other svg files
    #
    def sub_def_ids(content, classname)
      return content unless !!content.match(/<defs>/)

      content.scan(/<defs>.+<\/defs>/m).flatten.each do |defs|
        defs.scan(/id="(.+?)"/).flatten.uniq.each_with_index do |id, index|

          if content.match(/url\(##{id}\)/)
            new_id = "#{classname}-def#{index}"

            content = content.gsub(/id="#{id}"/, %Q{class="#{new_id}"})
                             .gsub(/url\(##{id}\)/, "url(##{new_id})" )
          else
            content = content.gsub(/id="#{id}"/, %Q{class="#{id}"})
          end
        end
      end

      content
    end

    def optimize?
      !!(config[:optimize] && svgo_cmd)
    end

    def svgo_cmd
      find_node_module('svgo')
    end


    def optimize(svg, attributes)
      if optimize?
        path = write_tmp '.svgo-tmp', svg
        command = "#{svgo_cmd} --disable=removeUselessDefs '#{path}' -o -"
        svg = `#{command}`
        FileUtils.rm(path) if File.exist? path
      end

      id_symbols(svg, attributes)
    end

    def id_symbols(svg, attr)
      svg.gsub(/<svg.+?>/).with_index do |match, index|
        %Q{<symbol #{attributes(attr[index])}>}     # Remove clutter from svg declaration
      end
        .gsub(/<\/svg/,'</symbol') # Replace svgs with symbols
        .gsub(/class=/,'id=')     # Replace classes with ids (classes are generated here)
        .gsub(/\w+=""/,'')      # Remove empty attributes
    end

    def compress(file)
      return if !config[:compress]

      mtime = File.mtime(file)
      gz_file = "#{file}.gz"

      return if (File.exist?(gz_file) && File.mtime(gz_file) >= mtime)

      File.open(gz_file, "wb") do |dest|
        gz = ::Zlib::GzipWriter.new(dest, Zlib::BEST_COMPRESSION)
        gz.mtime = mtime.to_i
        IO.copy_stream(open(file), gz)
        gz.close
      end

      File.utime(mtime, mtime, gz_file)

      gz_file
    end

    def write_tmp(name, content)
      path = File.join(config[:temp], name)
      FileUtils.mkdir_p(File.dirname(path))
      write_file path, content
      path
    end

    def read_tmp(name)
      path = File.join(config[:temp], name)
      if File.exist? path
        File.read path
      else
        ''
      end
    end

    def log_path(path)
      File.expand_path(path).sub(config[:pwd], '').sub(/^\//,'')
    end

    def write_file(path, contents)
      FileUtils.mkdir_p(File.expand_path(File.dirname(path)))
      File.open(path, 'w') do |io|
        io.write(contents)
      end
    end

    def key_id(keys)
      keys.map do |key|
        (key == '.') ? 'symbols' : classname(key)
      end.join('-')
    end

    # Determine if an NPM module is installed by checking paths with `npm bin`
    # Returns path to binary if installed
    def find_node_module(cmd)
      require 'open3'

      return @modules[cmd] unless @modules[cmd].nil?

      @modules[cmd] = begin
        local = "$(npm bin)/#{cmd}"
        global = "$(npm -g bin)/#{cmd}"

        if Open3.capture3(local)[2].success?
          local
        elsif Open3.capture3(global)[2].success?
          global
        else
          false
        end
      end
    end

    def symbolize_keys(hash)
      h = {}
      hash.each {|k,v| h[k.to_sym] = v }
      h
    end

    # Return non-empty key names for groups of svgs
    def valid_keys(keys)
      if keys.nil? || keys.empty?
        svg_symbols.keys
      else
        keys = [keys].flatten.map { |k| dasherize k }
        svg_symbols.keys.select { |k| keys.include? dasherize(k) }
      end
    end

    # Load aliases from configuration.
    #  returns a hash of aliasees mapped to a name.
    #  Converts configuration YAML:
    #    alias:
    #      foo: bar
    #      baz: zip, zop
    #  To output:
    #    { :bar => "foo", :zip => "baz", :zop => "baz" }
    #
    def load_aliases(aliases)
      a = {}
      aliases.each do |name,alternates|
        alternates.split(',').each do |val|
          a[dasherize(val.strip).to_sym] = dasherize(name.to_s)
        end
      end
      a
    end

    def get_alias(name)
      config[:aliases][dasherize(name).to_sym] || name
    end

    def production?
      config[:produciton] || if Esvg.rails?
        Rails.env.production?
      end
    end
  end
end