# hx - A very small website generator.
#
# Copyright (c) 2009 MenTaLguY <mental@rydia.net>
# 
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
# 
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

require 'cgi'
require 'rubygems'
require 'ostruct'
require 'set'
require 'date'
require 'time'
require 'fileutils'
require 'pathname'
require 'yaml'
require 'liquid'
require 'redcloth'

module Hx

class NoSuchEntryError < RuntimeError
end

class EditingNotSupportedError < RuntimeError
end

module Source
  def edit_entry(path, prototype=nil)
    raise EditingNotSupportedError, "Editing not supported for #{path}"
  end

  def each_entry
    raise NotImplementedError, "#{self.class}#each_entry not implemented"
  end
end

class NullSource
  include Source

  def each_entry
    self
  end
end

NULL_SOURCE = NullSource.new

class PathSubset
  include Source

  def initialize(source, options)
    @source = source
    @path_filter = Predicate.new(options[:only], options[:except])
  end 

  def edit_entry(path, prototype=nil)
    if @path_filter.accept? path
      @source.edit_entry(path, prototype) { |text| yield text }
    else
      raise EditingNotSupportedError, "Editing not supported for #{path}"
    end
    self
  end

  def each_entry
    @source.each_entry do |path, entry|
      yield path, entry if @path_filter.accept? path
    end
    self
  end
end

class PathSubset::Predicate
  def initialize(accept, reject)
    @accept_re = patterns_to_re(accept)
    @reject_re = patterns_to_re(reject)
  end

  def accept?(path)
    (not @accept_re or path =~ @accept_re) and
    (not @reject_re or path !~ @reject_re)
  end

  def patterns_to_re(patterns)
    return nil if patterns.nil? or patterns.empty?
    patterns = Array(patterns)
    Regexp.new("(?:#{patterns.map { |p| pattern_to_re(p) }.join("|")})")
  end
  private :patterns_to_re

  def pattern_to_re(pattern)
    "^#{pattern.scan(/(\*\*)|(\*)|([^*]+)/).map { |s2, s, r|
      case
      when s2
        ".*"
      when s
        "[^/]*"
      when r
        Regexp.quote(r)
      end
    }.join("")}$"
  end
  private :pattern_to_re
end

class Overlay
  include Source

  def initialize(*sources)
    @sources = sources
  end

  def edit_entry(path, prototype=nil)
    @sources.each do |source|
      begin
        source.edit_entry(path, prototype) { |text| yield text }
        break
      rescue EditingNotSupportedError
      end
    end
    self
  end

  def each_entry
    seen = Set[]
    @sources.each do |source|
      source.each_entry do |path, entry|
        yield path, entry unless seen.include? path
        seen.add path
      end
    end
    self
  end
end

module CircumfixPath
  include Source

  def initialize(source, options)
    @source = source
    @prefix = options[:prefix]
    @suffix = options[:suffix]
    prefix = Regexp.quote(@prefix.to_s)
    suffix = Regexp.quote(@suffix.to_s)
    @regexp = Regexp.new("^#{prefix}(.*)#{suffix}$")
  end

private
  def add_circumfix(path)
    "#{@prefix}#{path}#{@suffix}"
  end

  def strip_circumfix(path)
    path =~ @regexp ; $1
  end
end

class AddPath
  include CircumfixPath

  def edit_entry(path, prototype=nil)
    path = strip_circumfix(path)
    raise EditingNotSupportedError, "Editing not supported for #{path}" unless path
    @source.edit_entry(path, prototype) { |text| yield text }
    self
  end

  def each_entry
    @source.each_entry do |path, entry|
      yield add_circumfix(path), entry
    end
    self
  end
end

class StripPath
  include CircumfixPath

  def edit_entry(path, prototype=nil)
    path = add_circumfix(path)
    @source.edit_entry(path, prototype) { |text| yield text }
    self
  end

  def each_entry
    @source.each_entry do |path, entry|
      path = strip_circumfix(path)
      yield path, entry if path
    end
    self
  end
end

class Cache
  include Source

  def initialize(source, options={})
    @source = source
    @entries = nil
  end

  def edit_entry(path, prototype=nil)
    @source.edit_entry(path, prototype) { |text| yield text }
    self
  end

  def each_entry
    unless @entries
      @entries = []
      @source.each_entry do |path, entry|
        @entries << [path, entry]
      end
    end
    @entries.each do |path, entry|
      yield path, entry.dup
    end
    self
  end
end

class Sort
  include Source

  def initialize(source, options)
    @source = source
    @key_fields = Array(options[:sort_by] || []).map { |f| f.to_s }
    @reverse = !!options[:reverse]
  end

  def edit_entry(path, prototype=nil)
    @source.edit_entry(path, prototype) { |text| yield text }
    self
  end

  def each_entry
    entries = []
    @source.each_entry do |path, entry|
      entries << [path, entry]
    end
    unless @key_fields.empty?
      entries = entries.sort_by do |path, entry|
        @key_fields.map { |f| entry[f] }
      end
    end
    entries.reverse! if @reverse
    entries.each do |path, entry|
      yield path, entry
    end
    self
  end
end

Chain = Object.new
def Chain.new(source, options)
  filters = options[:chain] || []
  options = options.dup
  options.delete(:chain) # prevent inheritance
  for raw_filter in filters
    source = Hx.build_source(options, source, {}, raw_filter)
  end
  source
end

def self.make_default_title(options, path)
  name = path.split('/').last
  words = name.split(/[_\s-]/)
  words.map { |w| w.capitalize }.join(' ')
end

def self.get_pathname(options, key)
  dir = Pathname.new(options[key] || ".")
  if dir.relative?
    base_dir = Pathname.new(options[:base_dir])
    (base_dir + dir).cleanpath(true)
  else
    dir
  end
end

def self.get_default_author(options)
  options.fetch(:default_author, "nobody")
end

def self.local_require(options, library)
  saved_require_path = $:.dup
  begin
    $:.delete(".")
    $:.push Hx.get_pathname(options, :lib_dir).to_s
    require library
  ensure
    $:[0..-1] = saved_require_path
  end
end

def self.resolve_constant(qualified_name, root=Object)
  begin
    qualified_name.split('::').inject(root) { |c, n| c.const_get(n) }
  rescue NameError
    raise NameError, "Unable to resolve #{qualified_name}"
  end
end

def self.expand_chain(raw_source)
  case raw_source
  when Array # rewrite array to Hx::Chain
    return NULL_SOURCE if raw_source.empty?

    filter_defs = raw_source.dup
    first_filter = filter_defs[0] = filter_defs[0].dup

    raw_source = {
      'filter' => 'Hx::Chain',
      'options' => {'chain' => filter_defs}
    }

    if first_filter.has_key? 'source' # use input of first filter for chain
      raw_source['source'] = first_filter['source']
      first_filter.delete('source')
    end
  end
  raw_source
end

def self.build_source(options, default_input, sources, raw_source)
  raw_source = expand_chain(raw_source)

  if raw_source.has_key? 'source'
    input_name = raw_source['source']
    begin
      source = sources.fetch(input_name)
    rescue IndexError
      raise NameError, "No source named #{input_name} in scope"
    end
  else
    source = default_input
  end

  if raw_source.has_key? 'filter'
    if raw_source.has_key? 'options'
      filter_options = options.dup
      for key, value in raw_source['options']
        filter_options[key.intern] = value
      end
    else
      filter_options = options
    end
    filter = raw_source['filter']
    begin
      factory = Hx.resolve_constant(filter)
    rescue NameError
      library = filter.gsub(/::/, '/').downcase
      Hx.local_require(options, library)
      factory = Hx.resolve_constant(filter)
    end
    source = factory.new(source, filter_options)
  end

  if raw_source.has_key? 'only' or raw_source.has_key? 'except'
    source = PathSubset.new(source, :only => raw_source['only'],
                                    :except => raw_source['except'])
  end

  if raw_source.has_key? 'strip_prefix' or
     raw_source.has_key? 'strip_suffix'
    source = StripPath.new(source, :prefix => raw_source['strip_prefix'],
                                   :suffix => raw_source['strip_suffix'])
  end

  if raw_source.has_key? 'add_prefix' or raw_source.has_key? 'add_suffix'
    source = AddPath.new(source, :prefix => raw_source['add_prefix'],
                                 :suffix => raw_source['add_suffix'])
  end

  if raw_source.has_key? 'sort_by' or raw_source.has_key? 'reverse'
    source = Sort.new(source, :sort_by => raw_source['sort_by'],
                              :reverse => raw_source['reverse'])
  end

  source = Cache.new(source) if raw_source['cache']

  source
end

class Site
  include Source

  attr_reader :options
  attr_reader :sources
  attr_reader :outputs

  class << self
    private :new

    def load(io, config_path)
      raw_config = YAML.load(io)
      options = {}
      options[:base_dir] = File.dirname(config_path)
      for key, value in raw_config.fetch('options', {})
        options[key.intern] = value
      end

      if raw_config.has_key? 'require'
        for library in raw_config['require']
          Hx.local_require(options, library)
        end
      end

      raw_sources_by_name = raw_config.fetch('sources', {})
      source_names = raw_sources_by_name.keys

      # build source dependency graph
      source_dependencies = {}
      for name, raw_source in raw_sources_by_name
        raw_source = Hx.expand_chain(raw_source)
        if raw_source.has_key? 'source'
          source_dependencies[name] = raw_source['source']
        end
      end

      # calculate depth for each source in the graph
      source_depths = Hash.new(0)
      for name in source_names
        seen = Set[] # for cycle detection
        while source_dependencies.has_key? name
          if seen.include? name
            raise RuntimeError, "cycle in source graph at #{name}"
          end
          seen.add name
          depth = source_depths[name] + 1
          name = source_dependencies[name]
          source_depths[name] = depth if depth > source_depths[name]
        end
      end

      # depth-first topological sort
      depth_first_names = source_names.sort_by { |n| -source_depths[n] }

      sources = {}
      for name in depth_first_names
        raw_source = raw_sources_by_name[name]
        sources[name] = Hx.build_source(options, NULL_SOURCE, sources,
                                        raw_source)
      end

      outputs = []
      for raw_output in raw_config.fetch('outputs', [])
        outputs << Hx.build_source(options, NULL_SOURCE, sources, raw_output)
      end

      new(options, sources, outputs)
    end
  end

  def initialize(options, sources, outputs)
    @options = options
    @sources = sources
    @outputs = outputs
    @combined_output = Overlay.new(*@outputs)
  end

  def edit_entry(path, prototype=nil)
    @combined_output.edit_entry(path, prototype) { |text| yield text }
    self
  end

  def each_entry
    @combined_output.each_entry do |path, entry|
      yield path, entry
    end
    self
  end
end

class FileBuilder
  def initialize(output_dir)
    @output_dir = output_dir
  end

  def build_file(path, entry)
    filename = File.join(@output_dir, path)
    dirname = File.dirname(filename)
    FileUtils.mkdir_p dirname
    File.open(filename, "wb") do |stream|
      stream.write entry['content'].to_s
    end
  end
end

module Backend

class Hobix
  include Source

  def initialize(source, options)
    @entry_dir = Hx.get_pathname(options, :entry_dir)
  end

  def yaml_repr(value)
    YAML.parse(YAML.dump(value))
  end
  private :yaml_repr

  def edit_entry(path, prototype=nil)
    entry_filename = @entry_dir + "#{path}.yaml"
    begin
      text = entry_filename.read
      previous_mtime = entry_filename.mtime
    rescue Errno::ENOENT
      raise NoSuchEntryError, path unless prototype
      prototype = prototype.dup
      prototype['content'] = (prototype['content'] || "").dup
      content = prototype['content']
      def content.to_yaml_style ; :literal ; end
      native = YAML::DomainType.new('hobix.com,2004', 'entry', prototype)
      text = YAML.dump(native)
      previous_mtime = nil
    end
    text = yield text
    repr = YAML.parse(text)
    keys = {}
    repr.value.each_key { |key| keys[key.value] = key }
    %w(created updated).each { |name| keys[name] ||= yaml_repr(name) }
    update_time = Time.now
    update_time_repr = yaml_repr(update_time)
    previous_mtime ||= update_time
    previous_mtime_repr = yaml_repr(previous_mtime)
    repr.add(keys['created'], previous_mtime_repr) unless repr['created']
    repr.add(keys['updated'], update_time_repr)
    entry_filename.parent.mkpath()
    entry_filename.open('w') { |stream| stream << repr.emit }
    self
  end

  def each_entry
    Pathname.glob(@entry_dir + '**/*.yaml') do |entry_filename|
      path = entry_filename.relative_path_from(@entry_dir).to_s
      path.sub!(/\.yaml$/, '')
      entry = entry_filename.open('r') do |stream|
        YAML.load(stream).value
      end
      entry['updated'] ||= entry_filename.mtime
      entry['created'] ||= entry['updated']
      yield path, entry
    end
    self
  end
end

end

module Listing

class RecursiveIndex
  include Source

  def self.new(source, options)
    listing = super(source, options)
    if options.has_key? :limit
      listing = Limit.new(listing, :limit => options[:limit])
    end
    if options.has_key? :page_size
      listing = Paginate.new(listing, :page_size => options[:page_size])
    end
    listing
  end

  def initialize(source, options)
    @source = source
  end

  def each_entry
    indexes = Hash.new { |h,k| h[k] = {'items' => []} }
    @source.each_entry do |path, entry|
      components = path.split("/")
      until components.empty?
        components.pop
        index_path = (components + ["index"]).join("/")
        index = indexes[index_path]
        index['items'] << {'path' => path, 'entry' => entry}
        if entry['modified'] and
           (not index['modified'] or entry['modified'] > index['modified'])
          index['modified'] = entry['modified']
        end
      end
    end
    indexes.each do |path, entry|
      yield path, entry
    end
    self
  end
end

class Paginate
  include Source

  def initialize(source, options)
    @source = source
    @page_size = options[:page_size]
  end

  def each_entry
    @source.each_entry do |index_path, index_entry|
      items = index_entry['items'] || []
      if items.empty?
        index_entry = index_entry.dup
        index_entry['pages'] = [index_entry]
        index_entry['page_index'] = 0
        yield index_path, index_entry
      else
        pages = []
        n_pages = (items.size + @page_size - 1) / @page_size
        for num in 0...n_pages
          page_items = items[@page_size * num, @page_size]
          entry = index_entry.dup
          entry['items'] = page_items
          entry['prev_page'] = "#{num}"
          entry['next_page'] = "#{num+2}"
          entry['pages'] = pages
          entry['page_index'] = num
          pages << {'path' => "#{index_path}/#{num+1}", 'entry' => entry}
        end
        pages[0]['path'] = index_path
        pages[0]['entry'].delete('prev_page')
        if pages.size > 1
          index_name = index_path.split('/').last
          pages[0]['entry']['next_page'] = "#{index_name}/2"
          pages[1]['entry']['prev_page'] = "../#{index_name}"
        end
        pages[-1]['entry'].delete('next_page')
        pages.each do |page|
          yield page['path'], page['entry']
        end
      end
    end
    self
  end
end

class Limit
  include Source

  def initialize(source, options)
    @source = source
    @limit = options[:limit]
  end

  def each_entry
    @source.each_entry do |path, entry|
      if entry['items']
        trimmed_entry = entry.dup
        trimmed_entry['items'] = entry['items'][0...@limit]
      else
        trimmed_entry = entry
      end
      yield path, trimmed_entry
    end
    self
  end
end

end

module Output

class LiquidTemplate
  include Source

  module TextFilters
    def textilize(input)
      RedCloth.new(input).to_html
    end

    def escape_url(input)
      CGI.escape(input)
    end

    def escape_xml(input)
      CGI.escapeHTML(input)
    end

    def path_to_url(input, base_url)
      "#{base_url}#{input}"
    end

    def handleize(input)
      "id_#{input.to_s.gsub(/[^A-Za-z0-9]/, '_')}"
    end

    def xsd_datetime(input)
      input = Time.parse(input) unless Time === input
      input.xmlschema
    end
  end

  def initialize(source, options)
    @source = source
    @options = {}
    for key, value in options
      @options[key.to_s] = value
    end
    template_dir = Hx.get_pathname(options, :template_dir)
    # global, so all LiquidTemplate instances kind of have to agree on the
    # same template directory for things to work right
    Liquid::Template.file_system = Liquid::LocalFileSystem.new(template_dir)
    Liquid::Template.register_filter(TextFilters)
    template_file = template_dir + options[:template]
    @template = template_file.open('r') { |s| Liquid::Template.parse(s.read) }
    @extension = options[:extension]
  end

  def each_entry
    @source.each_entry do |path, entry|
      unless @extension.nil?
        output_path = "#{path}.#{@extension}"
      else
        output_path = path
      end
      output_entry = entry.dup
      output_entry['content'] = @template.render(
        'now' => Time.now,
        'options' => @options,
        'path' => path,
        'entry' => entry
      )
      yield output_path, output_entry
    end
    self
  end
end

end

end