# 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 'thread'
require 'set'
require 'pathname'
require 'tempfile'
require 'yaml'
require 'hx/path'
require 'set'

module Hx

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

class NoSuchEntryError < RuntimeError
end

class EditingNotSupportedError < RuntimeError
end

# minimal complete definition: each_entry_path + get_entry, or each_entry
module Filter
  def edit_entry(path, prototype=nil)
    raise EditingNotSupportedError, "Editing not supported for #{path}"
  end

  def each_entry_path(selector)
    each_entry(selector) { |path, entry| yield path }
  end

  def each_entry(selector)
    each_entry_path(selector) 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(Path.literal(path)) do |entry_path, entry|
      return entry
    end
    raise NoSuchEntryError, path
  end
end

class NullInput
  include Filter

  def each_entry(selector)
    self
  end
end

NULL_INPUT = NullInput.new

class PathSubset
  include Filter

  def patterns_to_selector(patterns)
    patterns.map { |p| Path::parse_pattern(p) }.inject { |a, b| a | b }
  end
  private :patterns_to_selector

  def initialize(input, options)
    @input = input
    only = patterns_to_selector(Array(options[:only] || []))
    except = patterns_to_selector(Array(options[:except] || []))
    except = ~except if except
    if only and except
      @selector = only & except
    else
      @selector = only || except || Path::ALL
    end
  end 

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

  def each_entry_path(selector, &block)
    @input.each_entry_path(@selector & selector, &block)
    self
  end

  def each_entry(selector, &block)
    @input.each_entry(@selector & selector, &block)
    self
  end

  def get_entry(path)
    raise NoSuchEntryError, path unless @selector.accept_path? path
    @input.get_entry(path)
  end
end

class Overlay
  include Filter

  def initialize(*inputs)
    @inputs = inputs
  end

  def each_entry_path(selector)
    seen = Set[]
    @inputs.each do |input|
      input.each_entry_path(selector) 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]
    @regexp = Path.make_circumfix_re(@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(selector)
    selector = selector.assume_circumfix(@prefix, @suffix)
    @input.each_entry_path(selector) do |path|
      yield add_circumfix(path)
    end
    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(selector)
    selector = selector.elide_circumfix(@prefix, @suffix)
    @input.each_entry_path(selector) do |path|
      yield strip_circumfix(path)
    end
    self
  end

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

def self.cache_scope
  saved_records = Thread.current[:hx_cache_records]
  Thread.current[:hx_cache_records] = {}
  begin
    yield
  ensure
    Thread.current[:hx_cache_records] = saved_records
  end
end

class Cache
  include Filter

  class Record
    def initialize
      @paths = nil
      @entries = {}
    end

    def clear_path(path)
      @paths = nil
      @entries.delete path
    end

    def get_entry_paths
      @paths || (@paths = yield)
    end

    def get_entry(path)
      begin
        entry = @entries.fetch(path)
      rescue IndexError
        begin
          entry = yield
        rescue NoSuchEntryError
          entry = nil
        end
        @entries[path] = entry
      end
      raise NoSuchEntryError, path unless entry
      entry
    end
  end

  attr_reader :input

  def initialize(input, options={})
    input = input.input while Cache === input
    @input = input
  end

  def get_cache_record
    (Thread.current[:hx_cache_records] ||= {})[@input] ||= Record.new
  end

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

  def each_entry_path(selector)
    get_cache_record.get_entry_paths do
      paths = []
      @input.each_entry_path(Path::ALL) { |path| paths << path }
      paths
    end.each do |path|
      yield path if selector.accept_path? path
    end
    self
  end

  def get_entry(path)
    get_cache_record.get_entry(path) { @input.get_entry(path) }
  end
end

class Sort
  include Filter

  def initialize(input, options)
    @input = Cache.new(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(selector)
    entries = []
    @input.each_entry(selector) 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
    raw_filter = Hx.expand_chain(raw_filter)
    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.get_input_names(raw_source)
  if raw_source.has_key? 'input'
    return Array(raw_source['input'])
  else
    []
  end
end

def self.build_source(options, default_input, sources, raw_source)
  input_names = get_input_names(raw_source)
  input_sources = input_names.map do |input_name|
    begin
      sources.fetch(input_name)
    rescue IndexError
      raise NameError, "No source named #{input_name} in scope"
    end
  end
  case input_sources.length
  when 0
    source = default_input
  when 1
    source = input_sources.first
  else
    source = Overlay.new(*input_sources)
  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
end

class Site
  include Filter

  attr_reader :options
  attr_reader :sources

  class << self
    private :new

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

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

      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', {})
      raw_outputs = raw_config.fetch('output', [])
      for name, raw_source in raw_sources_by_name
        raw_sources_by_name[name] = Hx.expand_chain(raw_source)
      end
      raw_outputs = raw_outputs.map! do |raw_output|
        Hx.expand_chain(raw_output)
      end

      # build input dependency graph
      source_dependencies = Hash.new { |h,k| h[k] = Set.new }
      source_count_by_dependency = Hash.new(0)
      for name, raw_source in raw_sources_by_name
        for input_name in Hx.get_input_names(raw_source)
          source_dependencies[name].add input_name
          source_count_by_dependency[input_name] += 1
        end
      end
      for raw_output in raw_outputs
        for input_name in Hx.get_input_names(raw_source)
          source_count_by_dependency[input_name] += 1
        end
      end

      source_names = raw_sources_by_name.keys

      # calculate depth for each input in the graph
      source_depths = Hash.new(0)
      to_process = Set.new(source_names)
      until to_process.empty?
        need_updating = Set.new
        for name in to_process
          depth = source_depths[name] + 1
          if depth >= source_names.length
            raise "cycle in source graph involving #{name}"
          end
          for input_name in source_dependencies[name]
            if depth > source_depths[input_name]
              source_depths[input_name] = depth
              need_updating.add input_name
            end
          end
        end
        to_process = need_updating
      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]
        source = Hx.build_source(options, NULL_INPUT, sources, raw_source)
        if source_count_by_dependency[name] > 1
          source = Cache.new(source, options)
        end
        sources[name] = source
      end

      outputs = []
      for raw_output in raw_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
    @output = Overlay.new(*outputs)
  end

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

  def each_entry_path(selector)
    @output.each_entry_path(selector) { |path| yield path }
    self
  end

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

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

def self.write_file(pathname, content, executable=false)
  parent = pathname.parent
  parent.mkpath()
  tempfile = Tempfile.new('.hx-out', parent.to_s)
  begin
    File.open(tempfile.path, "wb") { |stream| stream << content.to_s }
    base_mode = 0666
    base_mode |= 0111 if executable
    File.chmod(base_mode & ~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