#!/usr/bin/env ruby
###########################################################################

if ARGV.any? { |arg| ["-h", "--help"].include? arg }
  puts
  puts "usage:"
  puts "  serve <directory> [host[:port]]"
  puts
  exit
end

###########################################################################

if __FILE__[/serve-dev\.rb$/]
  require 'sinatra/reloader'
else
  ENV["RACK_ENV"] = "production"
end

require 'sinatra'
require 'haml'
# require 'tilt/haml'
# require 'tilt/coffee'

require 'socket'

require 'epitools/core_ext'
require 'epitools/rash'
require 'pathname'

###########################################################################

# Defaults
port       = 8888
host       = "0.0.0.0"
public_dir = Dir.pwd

# Parse commandline arguments (root dir or port)
ARGV.each do |arg|
  case arg
  when /^\d{2,5}$/
    port = arg.to_i
  when /^(\w+):(\d+)$/, /^([\d\.]+):(\d+)$/
    host = $1
    port = $2.to_i
  else
    if File.directory? arg
      public_dir = arg
    else
      puts "Error: #{arg.inspect} is not a directory."
      exit 1
    end
  end
end

###########################################################################

# begin
#   require 'puma'
#   set :server, :puma
# rescue LoadError
# end

set :machine_name, Socket.gethostname

set :bind, host
set :port, port

set :assets,     Pathname.new(__FILE__).expand_path.dirname / "assets"
set :root_dir,   Pathname.new(public_dir).expand_path
set :public_folder, nil

mime_type :mkv, 'video/x-matroska'
disable :sessions

puts %q{
 ____                  _                                  _ _
/ ___|  ___ _ ____   _(_)_ __   __ _   _ __ ___   ___  __| (_) __ _
\___ \ / _ \ '__\ \ / / | '_ \ / _` | | '_ ` _ \ / _ \/ _` | |/ _` |
 ___) |  __/ |   \ V /| | | | | (_| | | | | | | |  __/ (_| | | (_| |_ _ _
|____/ \___|_|    \_/ |_|_| |_|\__, | |_| |_| |_|\___|\__,_|_|\__,_(_|_|_)
                               |___/             }
puts

if host == "0.0.0.0"
  Socket.ip_address_list.each do |addr|
    puts "ADDRESS: http://#{addr.ip_address}:#{port}/" if addr.afamily == 2
  end
else
  puts "ADDRESS: http://#{host}:#{port}/"
end

puts " SOURCE: #{settings.root_dir}"
puts

###########################################################################

helpers do
  def highlight(thing, regexp)
    thing.to_s.gsub(regexp) { |m| "<mark>#{m}</mark>" }
  end

  def current_full_url
    "#{request.base_url}#{request.fullpath}"
  end

  def current_url
    "#{request.base_url}#{request.path}"
  end

  def url_for(relative_path)
    "#{request.base_url}/#{"#{@fullpath}/" if @fullpath.any?}#{relative_path}"
  end
end

###########################################################################

class Pathname

  alias_method :relative_to, :relative_path_from

  alias_method :ls,       :children
  alias_method :exists?,  :exist?
  alias_method :dir?,     :directory?

  def relative_name(root)
    dir = dir?
    name = relative_to(root).to_s
    dir ? "#{name}/" : name
  end

  def name
    if dir?
      "#{basename}/"
    else
      "#{basename}"
    end
  end


  TYPES = Rash.new(
    /\.(avi|ogm|webm|mpv|mp4|m4v|mkv|mj?pe?g|flv|mov|wmv)$/ => "video",
    /(^README|\.(pdf|doc|txt|srt|sub|nfo)$)/                => "doc",
    /\.(jpe?g|gif|png)$/                                    => "image",
    /\.(mp3|ogg|m4a|aac|flac)$/                             => "audio",
  )

  def type
    dir? ? "dir" : TYPES[basename.to_s] || "file"
  end

  def video?; type == "video"; end
  def audio?; type == "audio"; end
  def doc?;   type == "doc"; end
  def image?; type == "image"; end
  def media?; %w[audio video doc image].include? type; end

  def icon
    if dir?
      "/_/dir.gif"
    else
      "/_/#{type}.gif"
    end
  end



  SORT_METHOD = {
    "date" => :cmp_date,
    "size" => :cmp_size,
    "type" => :cmp_type,
    "name" => :cmp_name,
  }

  def children_sorted_by(sort="name")
    method = SORT_METHOD[sort] || :cmp_name
    children.select(&:exist?).sort_by &method
  end

  def cmp_date
    -mtime.to_i
  end

  def cmp_size
    [dir? ? 0 : -size, cmp_name]
  end

  def cmp_type
    [type, cmp_name]
  end

  def cmp_name
    [dir? ? 0 : 1, to_s.downcase]
  end


  #
  # Read xattrs from file (requires "getfattr" to be in the path)
  #
  def getxattrs
    # # file: Scissor_Sisters_-_Invisible_Light.flv
    # user.m.options="-c"

    cmd = %w[getfattr -d -m - -e base64] + [realpath.to_s]

    attrs = {}

    IO.popen(cmd, "rb", :err=>[:child, :out]) do |io|
      io.each_line do |line|
        if line =~ /^([^=]+)=0s(.+)/
          key   = $1
          value = $2.from_base64 # unpack base64 string
          # value = value.encode("UTF-8", "UTF-8") # set string's encoding to UTF-8
          value = value.force_encoding("UTF-8").scrub  # set string's encoding to UTF-8
          # value = value.encode("UTF-8", "UTF-8")  # set string's encoding to UTF-8

          attrs[key] = value
        end
      end
    end

    attrs
  end

end

###########################################################################

class Time
  def formatted_like_ls
    if year == Time.now.year
      fmt = "%b %d %H:%M"
    else
      fmt = "%b %d %Y"
    end

    strftime(fmt)
  end

  def rfc822
    strftime("%a, %-d %b %Y %T %z")
  end
  alias_method :rss, :rfc822
end

###########################################################################


def moin2md(moin)

  convert_tables = proc do |s|
    result = []
    chunks = s.each_line.chunk { |line| line.match? /^\s*\|\|.*\|\|\s*$/ }

    chunks.each do |is_table, lines|
      if is_table
        result << "<table>"

        lines.map do |line|
          cols = line.scan(/(?<=\|\|)([^\|]+)(?=\|\|)/).flatten

          result << "  <tr>"
          result << "    <td>#{cols.join("</td><td>")}</td>"
          result << "  </tr>"
        end

        result << "</table>"
      else
        result = lines.to_a
      end
    end

    result.join
  end

  markdown = moin.
    gsub(/^(={1,5}) (.+) =+$/) { |m| ("#" * $1.size ) + " " + $2 }. # headers
    gsub(/'''/, "__").                            # bolds
    gsub(/''/, "_").                              # italics
    gsub(/\{\{(?:attachment:)?(.+)\}\}/, "![](\\1)").  # images
    gsub(/\[\[(.+)\|(.+)\]\]/, "[\\2](\\1)").     # links w/ desc
    gsub(/\[\[(.+)\]\]/, "[\\1](\\1)").           # links w/o desc
    gsub(/^#acl .+$/, '').                        # remove ACLs
    gsub(/^<<TableOfContents.+$/, '').            # remove TOCs
    gsub(/^## page was renamed from .+$/, '').    # remove 'page was renamed'
    # TODO: use `html-renderer` to convert it to ANSI
    gsub(/^\{\{\{\n^#!raw\n(.+)\}\}\}$/m, "\\1"). # remove {{{#!raw}}}s
    # TODO: convert {{{\n#!highlight lang}}}s (2-phase: match {{{ }}}'s, then match first line inside)
    gsub(/\{\{\{\n?#!(?:highlight )?(\w+)\n(.+)\n\}\}\}$/m, "```\\1\n\\2\n```"). # convert {{{#!highlight lang }}} to ```lang ```
    gsub(/\{\{\{\n(.+)\n\}\}\}$/m, "```\n\\1\n```")  # convert {{{ }}} to ``` ```

  markdown = convert_tables[markdown]

  markdown
end


def moin2md(moin)

  convert_tables = proc do |s|
    result = []
    chunks = s.each_line.chunk { |line| line.match? /^\s*\|\|.*\|\|\s*$/ }

    flattened = chunks.map do |is_table, lines|
      if is_table

        lines.map.with_index do |line,i|
          cols = line.scan(/(?<=\|\|)([^\|]+)(?=\|\|)/).flatten

          newline = cols.join(" | ")
          newline << " |" if cols.size == 1
          newline << "\n"

          if i == 0
            sep = cols.map { |col| "-" * col.size }.join("-|-") + "\n"

            if cols.all? { |col| col.match? /__.+__/ } # table has headers!
              [newline, sep]
            else
              empty_header = (["..."]*cols.size).join(" | ") + "\n"
              [empty_header, sep, newline]
            end
          else
            newline
          end
        end

      else
        lines
      end
    end.flatten

    flattened.join
  end

  markdown = moin.
    gsub(/^(={1,5}) (.+) =+$/) { |m| ("#" * $1.size ) + " " + $2 }. # headers
    gsub(/'''/, "__").                            # bolds
    gsub(/''/, "_").                              # italics
    gsub(/\{\{(?:attachment:)?(.+)\}\}/, "![](\\1)").  # images
    gsub(/\[\[(.+)\|(.+)\]\]/, "[\\2](\\1)").     # links w/ desc
    gsub(/\[\[(.+)\]\]/, "[\\1](\\1)").           # links w/o desc
    gsub(/^#acl .+$/, '').                        # remove ACLs
    gsub(/^<<TableOfContents.+$/, '').            # remove TOCs
    gsub(/^## page was renamed from .+$/, '').    # remove 'page was renamed'
    # TODO: use `html-renderer` to convert it to ANSI
    gsub(/^\{\{\{\n^#!raw\n(.+)\}\}\}$/m, "\\1"). # remove {{{#!raw}}}s
    # TODO: convert {{{\n#!highlight lang}}}s (2-phase: match {{{ }}}'s, then match first line inside)
    gsub(/\{\{\{\n?#!(?:highlight )?(\w+)\n(.+)\n\}\}\}$/m, "```\\1\n\\2\n```"). # convert {{{#!highlight lang }}} to ```lang ```
    gsub(/\{\{\{\n(.+)\n\}\}\}$/m, "```\n\\1\n```")  # convert {{{ }}} to ``` ```

  markdown = convert_tables[markdown]

  markdown
end

###########################################################################

def flip_order(order)
  order == "reversed" ? "forwards" : "reversed"
end


def sort_params(field, current_sort, current_order)
  current_sort ||= "name"
  next_order     = "forwards"
  next_order     = flip_order(current_order) if current_sort == field

  "?sort=#{field}&order=#{next_order}"
end

###########################################################################
###########################################################################
#
# Secret assets directory
#
get '/_/*' do |path|
  if path == "default.css"
    content_type :css
    settings.templates[:css].first
  elsif path == "main.js"
    content_type :js
    settings.templates[:js].first
  else
    send_file settings.assets / path
  end
end

#
# Regular directories
#
get '/*' do |path|
  @path          = settings.root_dir / path
  @relative_path = @path.relative_to(settings.root_dir)
  @root_dir_name = settings.root_dir.basename
  @fullpath      = @relative_path.to_s
  @fullpath      = "" if @fullpath == "."

  return not_found unless @path.exists?

  #
  # Serve a file
  #
  unless @path.directory?
    css = settings.templates[:css].first

    return \
      case @path.extname
      when ".haml"
        haml @path.read, layout: false
      when ".md"
        output = markdown @path.read, layout: false
        "<style>code { white-space: pre-wrap; }</style>\n" + output
      when ".moin"

        output = markdown(moin2md(@path.read), layout: false)
        # "<style>code { white-space: pre-wrap; }</style>\n" + output
        "<style>#{css}</style>\n" + output
      when ".swf"
        if params[:file]
          send_file @path
        else
          haml :swf, layout: false
        end
      else
        case request.env["HTTP_ACCEPT"]
        when "metadata", "application/metadata+json"
          @path.getxattrs.to_json
        else
          send_file @path
        end
      end
  end

  #
  # Everything that's not a file
  #
  if params[:search]
    # TODO: link to directories

    # SEARCH

    if params[:throbber]
      @redirect = "/#{path}?search=#{params[:search]}"
      return haml :throbber
    end


    # Turn query into a regexp
    union = Regexp.union params[:search].split
    @query = /#{union.source}/i


    # @matches = @path.find.select(&:exist?).map do |file|
    #   begin
    #     rel = file.relative_to(@path)
    #     file if rel.to_s =~ @query
    #   rescue ArgumentError
    #   end
    # end.compact

    # Search directory tree for files
    # @matches = @path.find.select(&:exist?).map do |file|
    @matches = Dir["#{@path}/**/*"].select { |file| file =~ @query rescue nil }.map do |file|
      file = Pathname.new(file)
      next unless file.exists?
      rel = file.relative_to(@path)
      file if rel.to_s =~ @query
    end.compact

    # Group by dirs
    @grouped = @matches.group_by { |file| file.dirname }

    haml :search

  elsif feedtype = params[:rss]
    # RSS

    @files = @path.children_sorted_by("date")

    case feedtype
    when "video"
      @files = @files.select(&:video?)
    when "audio"
      @files = @files.select(&:audio?)
    when "doc"
      @files = @files.select(&:doc?)
    else
      @files = @files.select(&:media?)
    end

    content_type :atom
    # application/atom+xml
    haml :rss, layout: false
  else
    # FILES

    @sort  = params[:sort] || "name"
    @files = @path.children_sorted_by(@sort)

    if @order = params[:order]
      @files.reverse! if @order == "reversed"
    else
      @order = "forwards"
    end

    haml :files
  end
end


not_found do
  'Try again!'
end

###########################################################################
__END__

@@css
body { background-color: ffffff; font-family: Tahoma, Verdana, Arial, Helvetica, sans-serif; }
/*
body { background-color: #111111; color: #44aa44; font-family: Tahoma, Verdana, Arial, Helvetica, sans-serif; }
*/
#search { position: absolute; right: 5px; top: 5px; }
#rss { position: absolute; right: 300px; top: 5px; }

/* Directory Listing Header - "Filename", "Size" */
td { vertical-align: top; font-size: 10pt; }
td a { display: block; width: 100%; }
thead { font-weight: bold;  color: #eee; }
thead td { background-color: #777; }
thead a:link,
thead a:visited,
thead a:active,
thead a:hover { color: #eee; }

// https://css-tricks.com/snippets/html/responsive-meta-tag/

/* File Entries Within Listing */
.filefont { font-size: 10pt; }

/* Alternating Background Colors Within Listing */
.grey { background-color: #eeeeee; }

/* Link Formatting */
a { text-decoration: none; }        /* remove default underline from link */
a:link { color: #0000ff }           /* color for unvisited links */
a:visited { color: #000099 }        /* color for visited links */
a:active { color: #990000 }         /* color when link is clicked */
a:hover { color: #0000FF; text-decoration: underline }

dt { font-weight: bold; }
dd { margin-bottom: 5px; }
pre { margin: 0; }

@@layout
!!!
%html
  %head
    %meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"}
    %meta{:name => "viewport", :content => "width=device-width, initial-scale=1"}
    %title
      [#{settings.machine_name}]
      #{@root_dir_name} / #{@fullpath}

    %link{href: "/_/default.css", rel: "stylesheet", type: "text/css"}
    %script{src: "/_/main.js"}

  %body
    %h2
      [#{settings.machine_name}]
      %a{href: "/"} #{@root_dir_name}
      - @relative_path.descend do |fn|
        - next if fn.to_s == "."
        \/
        %a{href: "/#{fn}/"}= fn.basename

    #rss
      - %w[video audio doc].each do |type|
        %a{href: "?rss=#{type}"}
          %img{src: "/_/#{type}.gif"}

    %form#search
      %input{name: "search", placeholder: "Search", value: params[:search]}
      %input{type: "hidden", name: "throbber", value: "true"}
      / %input{type: "submit"}

    = yield




@@files
%table{cellpadding: 3}
  / HEADERS
  %thead
    %tr
      %td
        %a{href: sort_params("type", @sort, @order)} &nbsp;
      %td
        %a{href: sort_params("name", @sort, @order)} Filename
      %td{align: "right"}
        %a{href: sort_params("size", @sort, @order)} Size
      %td
        %a{href: sort_params("date", @sort, @order)} Date


  / FILE LISTING
  %tbody
    - @files.each_with_index do |file,i|

      %tr{class: (i % 2 == 0 ? "grey" : "")}
        / ICON
        %td.icon
          %img{src: file.icon}

        / NAME
        %td{style: "min-width:100px"}
          %a{href: file.name.urlencode}= file.name

        / SIZE
        %td{nowrap: true, align: 'right'}
          = file.dir? ? "" : file.size.commatize

        / DATE
        %td{nowrap: true, align: 'right'}
          = file.mtime.formatted_like_ls



@@throbber
%meta{"http-equiv": "refresh", content: "0;URL=#{@redirect}"}

%h2
  Searching for:
  %mark= params[:search]

%img{src: "/_/throbber.gif"}


@@search
%h2
  Results for:
  %mark= params[:search]


- @grouped.each do |dir, files|

  %h4
    %a{href: "/#{dir.relative_name(@path)}"}
      = highlight(dir.relative_name(@path), @query)

  %ul
    - files.each do |file|
      %a{href: file.relative_name(@path)}
        = highlight(file.relative_name(dir), @query)
      %br


@@rss
!!! XML
%rss(version="2.0"
     xmlns:content="http://purl.org/rss/1.0/modules/content/"
     xmlns:atom="http://www.w3.org/2005/Atom")

  %channel
    %title
      [#{settings.machine_name}]/#{@root_dir_name}/

    %link #{current_url}
    %atom:link(href="#{current_full_url}"
               rel="self"
               type="application/rss+xml")
    %language en-US
    %lastBuildDate #{@files.first.mtime.rss}

    - @files.each do |file|
      %item
        %title #{file.basename}
        %link  #{url_for(file.name)}
        %pubDate #{file.mtime.rss}


@@swf
!!! XML

%head
  %title
  :css
    body { background-color: black; margin: 0 0 0 0; padding: 0 0 0 0; }

%body
  %object{data: "#{@path.name}?file=yes", width: "100%", height: "100%"}


@@js

var init = function() {

  var empty_object = function(obj) {
    return Object.keys(obj).length === 0 && obj.constructor === Object
  }

  var get_metadata = function(url, callback) {
    console.log("getting metadata for:", url)

    var xhr = new XMLHttpRequest()
    xhr.open("GET", url)
    xhr.setRequestHeader("Accept", "application/metadata+json")
    xhr.onload = function() { callback(JSON.parse(xhr.responseText)) }
    xhr.send()
  }

  document.querySelectorAll(".icon").forEach(function(td) {
    var e = td.children[0]

    e.onclick = function() {

      var link_td = td.parentNode.children[1]
      var existing_metadata = link_td.querySelector(".metadata")

      if (existing_metadata) {
        existing_metadata.remove()
      } else {
        var url = link_td.querySelector("a")["href"]

        get_metadata(url, function(json) {

          var div = document.createElement("div")
          div.className = "metadata"

          if (empty_object(json)) {
            div.innerHTML = "<b>No metadata</b>"
          } else {
            html = "<dl>\n"

            for (var key in json) {
              html += "<dt>"+key.replace(/^user\.(dublincore\.)?/, '')+"</dt>\n"
              var val = json[key]
              console.log(key)

              if (key.match(/\.url$/)) {
                val = '<a href="'+val+'">'+val+'</a>'
              } else {
                val = "<pre>"+val+"</pre>"
              }
              html += "<dd>"+val+"</dd>\n"
            }
            html += "</dl>\n"

            div.innerHTML = html
          }

          link_td.appendChild(div)
        })
      }
    }
  })

  console.log("loaded!")
}

window.addEventListener("DOMContentLoaded", init, false)
