# hx - A very small website generator.
#
# Copyright (c) 2009-2010 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 'rubygems'
require 'thread'
require 'set'
require 'pathname'
require 'tempfile'
require 'yaml'

module Hx

VERSION = (Pathname.new(__FILE__).parent.parent + 'VERSION').read.strip

class NoSuchEntryError < RuntimeError
end

class EditingNotSupportedError < RuntimeError
end

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

  def each_entry_path
    each_entry { |path, entry| yield path }
  end

  def each_entry
    each_entry_path do |path|
      begin
        entry = get_entry(path)
      rescue NoSuchEntryError
        next # entries may come and go during the enumeration
      end
      yield path, entry
    end
  end

  def get_entry(path)
    each_entry do |entry_path, entry|
      return entry if entry_path == path
    end
    raise NoSuchEntryError, path
  end
end

class NullInput
  include Filter

  def each_entry
    self
  end
end

NULL_INPUT = NullInput.new

class PathSubset
  include Filter

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

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

  def each_entry_path
    @input.each_entry_path do |path|
      yield path if @path_filter.accept? path
    end
    self
  end

  def get_entry(path)
    raise NoSuchEntryError, path unless @path_filter.accept? path
    @input.get_entry(path)
  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 { |s,|
      case s
      when "**"; ".*"
      when "*"; "[^/]*"
      else Regexp.quote(s)
      end
    }}$"
  end
  private :pattern_to_re
end

class Overlay
  include Filter

  def initialize(*inputs)
    @inputs = inputs
  end

  def each_entry_path
    seen = Set[]
    @inputs.each do |input|
      input.each_entry_path do |path|
        yield path unless seen.include? path
        seen.add path
      end
    end
    self
  end

  def get_entry(path)
    @inputs.each do |input|
      begin
        return input.get_entry(path)
      rescue NoSuchEntryError
      end
    end
    raise NoSuchEntryError, path
  end
end

module CircumfixPath
  include Filter

  def initialize(input, options)
    @input = input
    @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
    @input.edit_entry(path, prototype) { |text| yield text }
    self
  end

  def each_entry_path
    @input.each_entry_path { |path| yield add_circumfix(path) }
    self
  end

  def get_entry(path)
    path = strip_circumfix(path)
    raise NoSuchEntryError, path unless path
    @input.get_entry(path)
  end
end

class StripPath
  include CircumfixPath

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

  def each_entry_path
    @input.each_entry_path do |path|
      path = strip_circumfix(path)
      yield path if path
    end
    self
  end

  def get_entry(path)
    @input.get_entry(add_circumfix(path))
  end
end

class Cache
  include Filter

  def initialize(input, options={})
    @input = input
    @lock = Mutex.new
    @entries = nil
    @entries_by_path = {}
  end

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

  def each_entry
    entries = nil
    @lock.synchronize do
      if @entries
        entries = @entries
      else
        entries = []
        @input.each_entry do |path, entry|
          @entries_by_path[path] = entry
          entries << [path, entry]
        end
        @entries = entries
      end
    end
    entries.each do |path, entry|
      yield path, entry.dup
    end
    self
  end

  def get_entry(path)
    entry = nil
    @lock.synchronize do
      if @entries_by_path.has_key? path
        entry = @entries_by_path[path]
      else
        entry = @input.get_entry(path)
        @entries_by_path[path] = entry
      end
    end
    return entry.dup
  end
end

class Sort
  include Filter

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

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

  def each_entry
    entries = []
    @input.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

  def get_entry(path)
    @input.get_entry(path)
  end
end

Chain = Object.new
def Chain.new(input, options)
  filters = options[:chain] || []
  options = options.dup
  options.delete(:chain) # prevent inheritance
  for raw_filter in filters
    input = Hx.build_source(options, input, {}, raw_filter)
  end
  input
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(".")
    lib_dir = Hx.get_pathname(options, :lib_dir)
    if lib_dir.relative?
      $:.push "./#{lib_dir}"
    else
      $:.push lib_dir.to_s
    end
    require library
  rescue LoadError
    raise
  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_input)
  case raw_input
  when Array # rewrite array to Hx::Chain
    return NULL_INPUT if raw_input.empty?

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

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

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

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

  if raw_source.has_key? 'input'
    input_name = raw_source['input']
    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 Filter

  attr_reader :options
  attr_reader :sources
  attr_reader :outputs

  class << self
    private :new

    def load_file(config_file)
      File.open(config_file, 'r') do |stream|
        load(stream, config_file)
      end
    end

    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 input 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? 'input'
          source_dependencies[name] = raw_source['input']
        end
      end

      # calculate depth for each input 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_INPUT, sources,
                                        raw_source)
      end

      outputs = []
      for raw_output in raw_config.fetch('outputs', [])
        outputs << Hx.build_source(options, NULL_INPUT, 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_path
    @combined_output.each_entry_path { |path| yield path }
    self
  end

  def get_entry(path)
    @combined_output.get_entry(path)
  end
end

def self.refresh_file(pathname, content, update_time)
  begin
    return false if update_time and update_time < pathname.mtime
  rescue Errno::ENOENT
  end
  write_file(pathname, content)
  true
end

def self.write_file(pathname, content)
  parent = pathname.parent
  parent.mkpath()
  tempfile = Tempfile.new('.hx-out', parent.to_s)
  begin
    File.open(tempfile.path, "wb") { |stream| stream << content.to_s }
    File.chmod(0666 & ~File.umask, tempfile.path)
    File.rename(tempfile.path, pathname.to_s)
  ensure
    tempfile.unlink
  end
  nil
end

class LazyContent
  def initialize(&block)
    raise ArgumentError, "No block given" unless block
    @lock = Mutex.new
    @content = nil
    @block = block
  end

  def to_s
    @lock.synchronize do
      if @block
        @content = @block.call
        @block = nil
      end
    end
    @content
  end

  def to_yaml(*args)
    to_s.to_yaml(*args)
  end

  def to_json(*args)
    to_s.to_json(*args)
  end

  def to_liquid(*args)
    to_s.to_liquid(*args)
  end
end

end