# 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 'set'
require 'pathname'
require 'yaml'

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 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
      @entries = entries
    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 = Pathname.new(output_dir)
  end

  def build_file(path, entry)
    filename = @output_dir + path
    dirname = filename.parent
    dirname.mkpath()
    filename.open("wb") do |stream|
      stream.write entry['content'].to_s
    end
    self
  end
end

end