#!/usr/bin/env ruby

require "find"
require "webrick"
require "open3"
require "optparse"

class ServeIt
  VERSION = [0, 0, 2]

  def self.main
    serve_dir, ignored_paths, command = parse_opts
    serve_dir = File.expand_path(serve_dir)
    Server.new(serve_dir, ignored_paths, command).serve
  end

  def self.parse_opts
    options = {:serve_dir => ".",
               :ignored_paths =>  []}
    parser = OptionParser.new do |opts|
      opts.banner = "Usage: #{$PROGRAM_NAME} [options] command"
      opts.on_tail("-s", "--serve-dir DIR", "Root directory for server") do |dir|
        options[:serve_dir] = dir
      end
      opts.on_tail("-i", "--ignore PATH", "Ignore changes to file or directory") do |path|
        options[:ignored_paths] << path
      end
      opts.on_tail("--version", "Show version") do |dir|
        puts ServeIt::VERSION.join('.')
        exit
      end
    end

    begin
      parser.parse!(ARGV)
    rescue OptionParser::InvalidOption => e
      $stderr.puts e
      $stderr.puts parser
      exit 1
    end

    if ARGV.count == 0
      command = nil
    elsif ARGV.count == 1
      command = ARGV.fetch(0)
    else
      $stderr.write parser.to_s
      exit 1
    end

    [options.fetch(:serve_dir), options.fetch(:ignored_paths), command]
  end

  class Server
    def initialize(serve_dir, ignored_paths, command)
      @mutex = Mutex.new
      @serve_dir = serve_dir
      @command = command
      @rebuilder = Rebuilder.new(@command, ignored_paths) if @command
    end

    def serve
      port = 8000
      puts "Starting server at http://localhost:#{port}"
      server = WEBrick::HTTPServer.new(:Port => port)

      server.mount_proc '/' do |req, res|
        relative_path = req.path.sub(/^\//, '')
        local_abs_path = File.absolute_path(relative_path, @serve_dir)

        if relative_path == "favicon.ico"
          respond_to_favicon(res)
        else
          respond_to_path(res, relative_path, local_abs_path)
        end
      end

      trap 'INT' do server.shutdown end
      server.start
    end

    def respond_to_favicon(res)
      res.status = 404
    end

    def respond_to_path(res, relative_path, local_abs_path)
      begin
        rebuild_if_needed
      rescue Rebuilder::RebuildFailed => e
        return respond_to_error(res, e.to_s)
      end

      if File.directory?(local_abs_path)
        respond_to_dir(res, relative_path, local_abs_path)
      else
        # We're building a file
        respond_to_file(res, local_abs_path)
      end
    end

    def respond_to_error(res, message)
      res.content_type = "text/html"
      res.body = "<pre>" + message + "</pre>"
    end

    def respond_to_dir(res, rel_path, local_abs_path)
      res.content_type = "text/html"
      res.body = (
        "<p><h3>Listing for /#{rel_path}</h3></p>\n" +
        Dir.entries(local_abs_path).select do |child|
          child != "."
        end.sort.map do |child|
          full_child_path_on_server = File.join("/", rel_path, child)
          %{<a href="#{full_child_path_on_server}">#{child}</a><br>}
        end.join("\n")
      )
    end

    def respond_to_file(res, local_abs_path)
      res.body = File.read(local_abs_path)
      res.content_type = guess_content_type(local_abs_path)
    end

    def guess_content_type(path)
      extension = File.extname(path).sub(/^\./, '')
      WEBrick::HTTPUtils::DefaultMimeTypes.fetch(extension) do
        "application/octet-stream"
      end
    end

    def rebuild_if_needed
      # Webrick is multi-threaded; guard against concurrent builds
      @mutex.synchronize do
        if @rebuilder
          @rebuilder.rebuild_if_needed
        end
      end
    end
  end

  class Rebuilder
    def initialize(command, ignored_paths)
      @command = command
      @ignored_paths = ignored_paths
      @last_disk_state = nil
    end

    def rebuild_if_needed
      if disk_state != @last_disk_state
        stdout_and_stderr, success = rebuild
        if !success
          message = "Failed to build! Command output:\n\n" + stdout_and_stderr
          raise RebuildFailed.new(message)
        end

        # Get a new post-build disk state so we don't pick up changes made during
        # the build.
        @last_disk_state = disk_state
        [stdout_and_stderr, success]
      end
    end

    def rebuild
      puts "Running command: #{@command}"
      puts " begin build".rjust(80, "=")
      start_time = Time.now
      stdout_and_stderr, status = Open3.capture2e(@command)
      print stdout_and_stderr
      puts (" built in %.03fs" % (Time.now - start_time)).rjust(80, "=")
      [stdout_and_stderr, status.success?]
    end

    def disk_state
      start_time = Time.now
      paths = []

      Find.find(".") do |path|
        if ignore_path?(path)
          Find.prune
        else
          paths << path
        end
      end

      paths.map do |path|
        [path, File.stat(path).mtime.to_s]
      end.sort.tap do
        puts (" scanned in %.03fs" % (Time.now - start_time)).rjust(80, "=")
      end
    end

    def ignore_path?(path)
      @ignored_paths.any? do |ignored_path|
        File.absolute_path(path) == File.absolute_path(ignored_path)
      end
    end

    class RebuildFailed < RuntimeError; end
  end
end

if __FILE__ == $PROGRAM_NAME
  ServeIt.main
end
