require 'fileutils'
require 'json'
require './harp_blog/post'
require './web_title_finder'
require './web_version_finder'

class HarpBlog

  class HarpBlogError < RuntimeError ; end
  class InvalidDataError < HarpBlogError ; end
  class PostExistsError < HarpBlogError ; end
  class PostAlreadyPublishedError < HarpBlogError ; end
  class PostNotPublishedError < HarpBlogError ; end

  class PostSaveError < HarpBlogError
    attr_reader :original_error
    def initialize(message, original_error)
      super(message)
      @original_error = original_error
    end
  end

  def initialize(path, dry_run = true, title_finder = nil, version_finder = nil)
    @path = path
    @dry_run = dry_run
    @title_finder = title_finder || WebTitleFinder.new
    @version_finder = version_finder || WebVersionFinder.new
    @mutated = false
  end

  def local_version
    git_sha
  end

  def remote_version
    @version_finder.find_version
  end

  def dirty?
    local_version != remote_version
  end

  def status
    local = local_version
    remote = remote_version
    {
      'local-version' => local,
      'remote-version' => remote,
      'dirty' => local != remote,
    }
  end

  def years
    update_if_needed
    Dir[post_path('20*')].map { |x| File.basename(x) }.sort
  end

  def months
    update_if_needed
    years.map do |year|
      # hack: month dirs (and only month dirs) are always 2 characters in length
      Dir[post_path("#{year}/??")].map { |x| [year, File.basename(x)] }
    end.flatten(1).sort
  end

  def posts_for_year(year)
    posts = []
    1.upto(12) do |n|
      month = n < 10 ? "0#{n}" : "#{n}"
      posts += posts_for_month(year, month)
    end
    posts
  end

  def posts_for_month(year, month)
    read_posts(File.join(year, month))
  end

  def drafts
    read_posts('drafts', draft: true)
  end

  def get_post(year, month, id)
    read_post(File.join(year, month), id)
  end

  def get_draft(id)
    read_post('drafts', id, draft: true)
  end

  def create_post(title, body, url, extra_fields = nil, options = nil)
    if !title || title.strip.length == 0
      title = url && find_title(url) or 'Untitled'
    end
    extra_fields ||= {}
    options ||= {}
    options[:commit] = true unless options.has_key?(:commit)
    fields = extra_fields.merge({
      title: title,
      link: url,
      body: body,
    })
    post = Post.new(fields)

    begin
      existing_post = read_post(post.dir, post.id, extra_fields)
    rescue InvalidDataError => e
      $stderr.puts "[HarpBlog#create_post] deleting post with invalid data: #{e.message}"
      delete_post_from_dir(post.dir, post.id)
      existing_post = nil
    end

    if existing_post
      raise PostExistsError.new("post exists: #{post.dir}/#{post.id}")
    else
      save_post(post)
      if options[:commit]
        git_commit("create post '#{post.title}'", [year_index_path("#{post.time.year}"), post_path(post.dir)])
      end
      post
    end
  end

  def update_post(post, title, body, link, timestamp = nil)
    post.title = title
    post.body = body
    post.link = link
    post.timestamp = timestamp if timestamp
    save_post(post)
    git_commit("update post '#{post.title}'", [post_path(post.dir)])
    post
  end

  def delete_post(year, month, id)
    dir = File.join(year, month)
    delete_post_from_dir(dir, id)
    git_commit("delete post #{year}/#{month}/#{id}", [post_path(dir)])
  end

  def delete_draft(id, options = nil)
    options ||= {}
    options[:commit] = true unless options.has_key?(:commit)
    delete_post_from_dir('drafts', id)
    if options[:commit]
      git_commit("delete draft #{id}", [post_path('drafts')])
    end
  end

  def publish_post(post)
    if post.draft?
      new_post = create_post(post.title, post.body, post.link, {}, {commit: false})
      delete_post_from_dir('drafts', post.id)
      build_rss
      git_commit("publish '#{quote(post.title)}'", [post_path('drafts'), post_path(new_post.dir), root_data_path])
      new_post
    else
      raise PostAlreadyPublishedError.new("post is already published: #{post.dir}/#{post.id}")
    end
  end

  def unpublish_post(post)
    if post.draft?
      raise PostNotPublishedError.new("post is not published: #{post.dir}/#{post.id}")
    else
      new_post = create_post(post.title, post.body, post.link, {draft: true}, {commit: false})
      delete_post_from_dir(post.dir, post.id)
      build_rss
      git_commit("unpublish '#{quote(post.title)}'", [post_path(post.dir), post_path('drafts'), root_data_path])
      new_post
    end
  end

  def publish(env)
    target = env.to_s == 'production' ? 'publish' : 'publish_beta'
    success, output = run("make #{target}")
    commit_root_data
    [success, output]
  end

  def sync
    update_if_needed
    git_push
  end

  def compile
    success, output = run('make compile')
    if success
      @mutated = false
    else
      puts output
    end
    success
  end

  def compile_if_mutated
    compile if @mutated
  end


############################################################################################
  private ##################################################################################
############################################################################################

  def find_title(url)
    @title_finder.find_title(url)
  end

  def path_for(*components)
    File.join(@path, *components)
  end

  def root_data_path
    path_for('public/_data.json')
  end

  def year_index_path(year)
    path_for('public/posts', year, 'index.ejs')
  end

  def post_path(dir, id = nil)
    args = ['public/posts', dir]
    args << "#{id}.md" if id
    path_for(*args)
  end

  def read_posts(post_dir, extra_fields = nil)
    update_if_needed
    extra_fields ||= {}
    post_data = read_post_data(post_path(post_dir))
    post_data.sort_by do |k, v|
      (v['timestamp'] || Time.now).to_i
    end.map do |id, fields|
      fields[:id] = id
      unless extra_fields[:draft]
        fields[:slug] = id
      end
      post_filename = post_path(post_dir, id)
      fields[:body] = File.read(post_filename)
      Post.new(fields.merge(extra_fields))
    end
  end

  def read_post(post_dir, id, extra_fields = nil)
    update_if_needed
    post_filename = post_path(post_dir, id)
    post_data = read_post_data(post_path(post_dir))
    if File.exist?(post_filename) && fields = post_data[id]
      fields[:body] = File.read(post_filename)
      if extra_fields
        fields.merge!(extra_fields)
      end
      fields[:id] = id
      unless fields[:draft]
        fields[:slug] = id
      end
      Post.new(fields)
    elsif fields
      message = "missing post body for #{post_dir}/#{id}: #{post_filename}"
      $stderr.puts "[HarpBlog#read_post] #{message}"
      raise InvalidDataError.new(message)
    elsif File.exist?(post_filename)
      message = "missing metadata for #{post_dir}/#{id}: #{post_dir}/_data.json"
      $stderr.puts "[HarpBlog#read_post] #{message}"
      raise InvalidDataError.new(message)
    end
  end

  def save_post(post)
    update_if_needed
    begin
      write_post(post)
      post

    rescue => e
      $stderr.puts "#{e.class}: #{e.message}"
      $stderr.puts e.backtrace
      git_reset_hard
      raise PostSaveError.new('failed to save post', e)
    end
  end

  def delete_post_from_dir(post_dir, id)
    update_if_needed
    post_dir = post_path(post_dir)
    delete_post_body(post_dir, id)
    delete_post_index(post_dir, id)
  end

  def write_post(post)
    post_dir = post_path(post.dir)
    unless post.draft?
      ensure_post_dir_exists(post_dir)
    end
    write_post_body(post_dir, post.id, post.body)
    begin
      write_post_index(post_dir, post.id, post.persistent_fields)
    rescue => e
      $stderr.puts "#{e.class}: #{e.message}"
      $stderr.puts e.backtrace
      delete_post_body(post_dir, post.id)
      raise e
    end
  end

  def write_post_body(dir, id, body)
    post_filename = File.join(dir, "#{id}.md")
    write_file(post_filename, body)
  end

  def delete_post_body(dir, id)
    post_filename = File.join(dir, "#{id}.md")
    delete_file(post_filename)
  end

  def write_post_index(dir, id, fields)
    post_data = read_post_data(dir)
    post_data[id] = fields
    write_post_data(dir, post_data)
  end

  def delete_post_index(dir, id)
    post_data = read_post_data(dir)
    if post_data[id]
      post_data.delete(id)
      write_post_data(dir, post_data)
    end
  end

  def ensure_post_dir_exists(dir)
    monthly_index_filename = File.join(dir, 'index.ejs')
    unless File.exist?(monthly_index_filename)
      source = File.join(dir, '../../2006/02/index.ejs')
      cp(source, monthly_index_filename)
    end

    yearly_index_filename = File.join(dir, '../index.ejs')
    unless File.exist?(yearly_index_filename)
      source = File.join(dir, '../../2006/index.ejs')
      cp(source, yearly_index_filename)
    end
  end

  def commit_root_data
    git_commit('commit root _data.json', [root_data_path])
  end

  def read_post_data(dir)
    post_data_filename = File.join(dir, '_data.json')
    if File.exist?(post_data_filename)
      JSON.parse(File.read(post_data_filename))
    else
      {}
    end
  end

  def write_post_data(dir, data)
    post_data_filename = File.join(dir, '_data.json')
    json = JSON.pretty_generate(data)
    write_file(post_data_filename, json)
  end

  def ensure_dir_exists(dir)
    unless File.directory?(dir)
      if @dry_run
        puts ">>> mkdir -p '#{dir}'"
      else
        FileUtils.mkdir_p(dir)
      end
    end
  end

  def cp(source, destination, clobber = false)
    ensure_dir_exists(File.dirname(destination))
    if !File.exist?(destination) || clobber
      if @dry_run
        puts ">>> cp '#{source}' '#{destination}'"
      else
        FileUtils.cp(source, destination)
      end
    end
  end

  def write_file(filename, data)
    ensure_dir_exists(File.dirname(filename))
    if @dry_run
      puts ">>> write file '#{filename}', contents:"
      puts data
    else
      File.write(filename, data)
    end
  end

  def delete_file(filename)
    if File.exist?(filename)
      if @dry_run
        puts ">>> unlink '#{filename}'"
      else
        File.unlink(filename)
      end
    end
  end

  def quote(s)
    s.gsub('"', '\\"')
  end

  def run(cmd, safety = :destructive)
    if safety == :destructive && @dry_run
      puts ">>> cd '#{@path}' && #{cmd}"
      [true, '']
    else
      output = `cd '#{@path}' && #{cmd} 2>&1`
      [$?.success?, output]
    end
  end

  def git_sha
    update_if_needed
    _, output = run 'git log -n1 | head -n1 | cut -d" " -f2', :nondestructive
    output.strip
  end

  def git_commit(message, files)
    quoted_files = files.map { |f| "\"#{quote(f)}\"" }
    puts ">>> git add -A #{quoted_files.join(' ')} && git commit -m \"#{message}\""
    success, output = run("git add -A #{quoted_files.join(' ')} && git commit -m \"#{message}\"")
    if success
      @mutated = true
    else
      puts output
    end
    [success, output]
  end

  def git_reset_hard(ref = nil)
    args = ref ? "'#{ref}'" : ''
    run("git reset --hard #{args}")
  end

  def git_push force = false
    args = force ? '-f' : nil
    run "git push #{args}"
  end

  def origin_updated_path
    File.join @path, 'origin-updated'
  end

  def git_update(remote = 'origin')
    if run "git update #{remote}", :nondestructive
      File.unlink origin_updated_path if origin_updated?
    end
  end

  def origin_updated?
    File.exist? origin_updated_path
  end

  def update_if_needed
    git_update if origin_updated?
  end

  def build_rss
    run 'make rss'
  end

end
