#!/usr/bin/env ruby
# Mouch - couch app purism
# (c) 2011 Johannes J. Schmidt, TF
#
# The `mouch` command generates a Mouch App from an app file,
# specified via APP parameter and pushs it to a CouchDB server.
# 
# Usage
# -----
#
#   ./mouch [FILENAME] [URLS]
#
# Render app:
#   ./mouch [FILENAME]
#   eg:
#     ./mouch app.json.erb
#     echo '{ "read_me": <%=h read "README" %> }' | ./mouch
#
# Push app:
#   ./mouch [FILENAME] URLS
#   eg:
#     ./mouch app.json.erb http://localhost:5984/myapp
#     echo '{ "read_me": <%=h read "README" %> }' | ./mouch http://localhost:5984/myapp
#
# Running the tests
#   ./mouch --test

require 'erb'
require 'json'
require 'tempfile'
require 'test/unit'

module Mouch
  module Build
    # feel free to add more, but only the ones you really use
    MIME_TYPES = {
      ".css"  => "text/css; charset=utf-8",
      ".gif"  => "image/gif",
      ".html" => "text/html; charset=utf-8",
      ".xml"  => "application/xml; charset=utf-8",
      ".ico"  => "image/vnd.microsoft.icon",
      ".jpeg" => "image/jpeg",
      ".jpg"  => "image/jpeg",
      ".js"   => "application/javascript; charset=utf-8",
      ".json" => "application/json; charset=utf-8",
      ".mp4"  => "video/mp4",
      ".pdf"  => "application/pdf",
      ".png"  => "image/png",
      ".svg"  => "image/svg+xml; charset=utf-8",
      ".tiff" => "image/tiff"
    }

    # escape content
    def h content
      content.to_json
    end

    # encode base64
    def base64 content
      [content].pack("m").gsub(/\s+/,'')
    end

    # read files
    # files can be one filename, a pattern
    # or an array of filenames and/or patterns
    def read patterns
      Dir[*patterns].uniq.map do |f|
        # absolute path
        filename = File.expand_path(f)

        # I am verbose
        STDERR.puts filename

        case File.extname(filename)
        when '.erb'
          # evaluate erb templates
          Dir.chdir File.dirname(filename) do
            ERB.new(File.read(File.basename(filename))).result(binding)
          end
        else
          File.read filename
        end
      end.join("\n")
    end

    # map directory structure
    # to javascript objects
    def map dirname, to_json = true
      out = {}
      
      Dir.entries(dirname).each do |f|
        next if f =~ /^\./
        file = File.join(dirname, f)

        # templates gets evaluated at build time
        key = f.sub(/\.erb$/, '')

        # keys are without extension
        key.sub!(/#{Regexp.escape File.extname(key)}$/, '')
        
        # map directories recursively
        out[key] = File.directory?(file) ? map(file, false) : read(file)
      end

      to_json ? out.to_json : out
    end

    # build inline attachment object
    # with base64 encoded data
    def attachment filename, patterns = filename, to_json = true
      out = {
        "content_type" => MIME_TYPES[File.extname(filename).downcase] || "application/octet-stream",
        "data" => base64(read(patterns))
      }

      to_json ? out.to_json : out
    end

    # map directory structure
    # to attachments data
    def attachments patterns, to_json = true
      out = {}

      Dir[*patterns].each do |file|
        # templates gets evaluated at build time
        key = file.sub(/\.erb$/, '')

        if File.directory?(file)
          out.merge! attachments(Dir[File.join(file, '/*')], false)
        else
          out[key] = attachment(file, file, false)
        end
      end

      to_json ? out.to_json : out
    end

    # convert images using ImageMagick
    def convert patterns, format = 'png', options = nil
      Dir[*patterns].uniq.map do |f|
        filename = File.join(Dir.pwd, f)

        # I am verbose
        STDERR.puts filename

        `convert #{filename} #{options} #{format}:-`
      end.join("\n")
    end
  end

  class App
    attr_reader :length

    def initialize docs, url, &block
      @docs = docs
      @url = url
      @block = block
      @length = docs.length
    end

    def get_revs &block
      docs = {}
      @docs.each do |doc| 
        doc.delete '_rev'
        docs[doc['_id']] = doc
      end

      file = Tempfile.new('ids.json')
      current = {}
      keys = @docs.map { |d| d['_id'] }.compact

      if keys.length > 0
        begin
          file << { "keys" => keys }.to_json
          file.rewind
          current = `curl --insecure -s -XPOST #{@url}/_all_docs -H 'Content-Type:application/json' -d@'#{file.path}'`
          begin
            current = JSON.parse current
          rescue JSON::ParserError
            block.call :error => "Can't connect to CouchDB server"
          end

          # check response
          if current["error"]
            block.call current
          end
        ensure
          file.close
        end

        # parse response
        if current['rows']
          current['rows'].each do |row|
            doc = docs[row['id']]
            doc['_rev'] = row['value']['rev'] if doc
          end
        end
      end
    end

    def push &block
      file = Tempfile.new('app.json')

      begin
        file << { "docs" => @docs }.to_json
        file.rewind
        response = `curl --insecure -s -XPOST #{@url}/_bulk_docs -d@'#{file.path}' -H 'Content-Type: application/json'`

        begin
          response = JSON.parse response
        rescue JSON::ParserError
          block.call :error => "Can't connect to CouchDB server"
        end

        # check response
        response.each do |resp|
          if resp["error"]
            block.call resp
          end
        end
      ensure
        file.close!
      end
    end
  end

  def self.info url, &block
    # check server
    info = `curl --insecure -s -XGET #{url} -H 'Content-Type:application/json'`
    begin
      info = JSON.parse info
    rescue JSON::ParserError
      block.call :error => "Can't connect to CouchDB server"
    end
    if info["error"]
      if !@retry && info["error"] === "not_found" && info["reason"] === "no_db_file"
        @retry = true
        info = `curl --insecure -s -XPUT #{url} -H 'Content-Type:application/json'`
        self.info url, &block
      else
        block.call info
      end
    end
  end

  def self.push json, url, batch_size = 100, &block
    info url, &block

    # dont modify json
    json = json.dup

    # push app in batches
    json['docs'].each_slice(batch_size) do |docs|
      app = App.new docs, url, &block

      app.get_revs &block
      app.push &block

      block.call nil, { 'length' => app.length }
    end
  end
end

class TestBuild < Test::Unit::TestCase
  include Mouch::Build

  TEST_DIR = '.test'

  def with_files files, &block
    system "rm #{TEST_DIR} -rf && mkdir #{TEST_DIR}"
    
    files.each do |filename, content|
      File.open(File.join(TEST_DIR, filename), 'w') { |f| f << content }
    end

    Dir.chdir TEST_DIR, &block
    
    system "rm #{TEST_DIR} -rf"
  end

  def test_h
    assert_equal("2", h(2))
  end

  def test_base64
    assert_equal("YSBzdHJpbmc=", base64('a string'))
  end

  def test_read
    with_files 'myfile' => 'bla' do
      assert_equal('bla', read('myfile'))
    end
  end

  def test_map
    with_files 'myfile.txt' => 'bla' do
      assert_equal({'myfile' => 'bla'}.to_json, map('.'))
    end
  end

  def test_attachments
    with_files 'mystyle.css' => 'body{color:red}' do
      assert_equal({
        'mystyle.css' => {
          'content_type' => 'text/css; charset=utf-8',
          'data' => 'Ym9keXtjb2xvcjpyZWR9'
        }
      }.to_json, attachments('*'))
    end
  end
end


include Mouch::Build

@@run_tests = false

if ARGV.delete('--test')
  # run tests
  @@run_tests = true
elsif ARGV.any? { |arg| arg =~ /^https{0,1}:\/\// } || ENV['COUCH_URL']
  # push app
  urls = ARGV.find_all { |arg| arg =~ /^https{0,1}:\/\// }
  ARGV.delete_if { |arg| arg =~ /^https{0,1}:\/\// }

  # use environment variable if present
  urls << ENV['COUCH_URL'] if ENV['COUCH_URL']

  app = []

  if File.file?(ARGF.filename)
    app << read(ARGF.filename)
  else
    # read from STDIN
    app << ERB.new(ARGF.read).result(binding)
  end

  # parse app
  @app = JSON.parse app.join

  # single doc
  if @app['_id']
    @app = { 'docs' => [@app] }
  end

  # check app
  if !@app['docs'] || !@app['docs'].is_a?(Array)
    puts 'Invalid app: no docs array.'
    return
  end

  urls.each do |url|
    STDERR.puts '* push %s' % url

    cnt = 0
    total = @app['docs'].length
    Mouch.push @app, url do |err, resp|
      if err
        STDERR.puts "An error happened:"
        STDERR.puts '  ' + err.inspect
      else
        cnt += resp['length']

        # progress indicator
        if total < 10
          STDERR.print "%d %s pushed" % [cnt, cnt === 1 ? 'doc' : 'docs']
        else
          STDERR.print "\r%.2f%% (%d/%d)" % [cnt * 100.0 / total, cnt, total]
        end
      end
    end

    STDERR.puts
  end
else
  # compile to STDOUT
  if File.file?(ARGF.filename)
    puts read ARGF.filename
  else
    # read from STDIN
    puts ERB.new(ARGF.read).result(binding)
  end
end

# instruct the test runner to run only if desired
class Test::Unit::Runner
  @@stop_auto_run = !@@run_tests
end