# frozen_string_literal: true

require 'psych'
require 'fileutils'
require 'erb'
require 'json'
require 'rack'
require 'mimemagic'
require 'active_support/all'
require 'ruby_dig'
require 'base64'
require 'digest'
require 'English'

# The VizBuilder class does all the stuff for a viz builder app
class VizBuilder
  attr_reader :config
  delegate :sitemap, :data, :hooks, :helper_modules, to: :config

  BUILD_DIR = 'build'
  PREBUILT_DIR = 'prebuild'
  DATA_DIR = 'data'

  def initialize(config = {}, &blk)
    # Config is an object used as the context for the given block
    @config = Config.new(config: config)
    @config_block = block_given? ? blk : nil
    @config.helpers(Helpers)
    @config.helpers(ConfigHelpers, type: :config)
    @config.helpers(TemplateHelpers, type: :template)
    @_configured = false
  end

  # Generate all pages in the sitemap and save to `build/`
  def build!(silent: false)
    configure!(mode: :build, target: :production)
    index_prebuilt!
    # First we build prebuilt pages that need digests calculated by build_page
    digested = sitemap.select { |_path, page| page[:digest] == true }
    digested.each { |path, _page| build_page(path, silent: silent) }
    # Then we build all other pages
    undigested = sitemap.reject { |_path, page| page[:digest] == true }
    undigested.each { |path, _page| build_page(path, silent: silent) }
  end

  # Run this builder as a server
  def runserver!(host: '127.0.0.1', port: '3456')
    configure!(mode: :server, target: :development)
    status = 0 # running: 0, reload: 1, exit: 2
    # spawn a thread to watch the status flag and trigger a reload or exit
    monitor = Thread.new do
      sleep 0.1 while status.zero?
      # Shutdown the server, wait for it to finish and then wait a tick
      Rack::Handler::WEBrick.shutdown
      sleep 0.1
      # Use ps to get the command that the user executed, and use Kernel.exec
      # to execute the command, replacing the current process.
      # Basically restart everything.
      Kernel.exec(`ps #{$PID} -o command`.split("\n").last) if status == 1
    end

    # trap ctrl-c and set status flag
    trap('SIGINT') do
      if status == 1
        status = 2 # ctrl-c hit twice or more, set status to exit
      elsif status.zero?
        # ctrl-c hit once, notify user and set status to reload
        puts "\nReloading the server, hit ctrl-c twice to exit\n"
        status = 1
      end
    end

    puts "\nStarting Dev server, hit ctrl-c once to reload, twice to exit\n"
    require 'webrick/accesslog'
    access_log = [[$stderr, WEBrick::AccessLog::COMMON_LOG_FORMAT]]
    Rack::Handler::WEBrick.run(self, Host: host, Port: port, AccessLog: access_log)
    monitor.join # let the monitor thread finish its work
  end

  # Support the call method so an instance can act as a Rack app
  def call(env)
    raise 'VizBuilder configuration incomplete!' unless @_configured

    # Only support GET, OPTIONS, HEAD
    unless env['REQUEST_METHOD'].in?(%w[GET HEAD OPTIONS])
      return [405, { 'Content-Type' => 'text/plain' }, ['METHOD NOT ALLOWED']]
    end

    # default response is 404 not found
    status = 404
    content_type = 'text/plain'
    content = '404 File not found'

    # Validate the requested path
    path = env['PATH_INFO']
    if path =~ %r{/$}
      # path is for a directory
      path += 'index.html'
    elsif path =~ %r{/[^./]+$}
      # path looks like a directory but is missing a trailing slash
      path += '/index.html'
    end

    # remove any leading slashes
    path = path[1..-1] if path =~ %r{^/}

    # files starting with _ are hidden
    unless File.basename(path) =~ /^_/
      # filename for a potential prebuilt asset
      build_filename = File.join(PREBUILT_DIR, path)

      # Check our sitemap then our prebuilt folder for content to serve
      if sitemap[path].present?
        content_type = MimeMagic.by_path(path).to_s
        content = build_page(path)
        status = 200
      elsif File.exist?(build_filename)
        content_type = MimeMagic.by_path(path).to_s
        content = File.read(build_filename)
        status = 200
      end
    end

    # Status code, headers and content for this response
    [status, { 'Content-Type' => content_type }, [content]]
  rescue StandardError => e
    full_exception = "#{e.class.name}: #{e.message}\n\n\t#{e.backtrace.join("\n\t")}"
    puts full_exception
    [500, { 'Content-Type' => 'text/plain' }, [full_exception]]
  end

  # Force a reload of data files in `data/`
  def reload_data!
    data.merge!(load_data)
    # TODO: after load hooks only run once, not every time reload_data! is called
    run_hook!(:after_load_data)
    # Chainable
    self
  end

  # Complete the configuration for this app. Is used by build! and runserver!.
  # You will only need this method if you're running VizBuilder as a rack app
  # without using runserver!.
  def configure!(kwargs = nil)
    return self if @_configured
    # Update config with any kwargs
    @config.merge!(kwargs) if kwargs.present?
    # Load data into @data
    reload_data!
    # Execute the given block as a method of this class. The block can then use
    # the methods `add_page`, `add_data`, and `set`
    @config.instance_exec(&@config_block) if @config_block.present?
    # Run any after load data hooks since they got added after the first load
    # data was called.
    run_hook!(:after_load_data)
    # Add helpers to the TemplateContext class
    TemplateContext.include(*helper_modules.reverse) unless helper_modules.empty?
    # Mark app configured
    @_configured = true
    # Chainable
    self
  end

  # Like File.extname, but gets all extensions if multiple are present
  def self.fullextname(path)
    fname = File.basename(path)
    parts = fname.split('.')
    ".#{parts[1..-1].join('.')}"
  end

  # Plaster over API changes in ERB
  def self.erb_new(template_path)
    # The parameters of ERB.new are changed in ruby 2.6
    erb_changed_ruby_version = Gem::Version.new('2.6.0')
    current_ruby_version = Gem::Version.new(RUBY_VERSION)
    erb = if current_ruby_version >= erb_changed_ruby_version
            ERB.new(File.read(template_path), trim_mode: '-')
          else
            ERB.new(File.read(template_path), nil, '-')
          end
    erb.filename = File.expand_path(template_path)
    erb
  end

  # Convenience methods for configuring the site
  class Config
    attr_accessor :data, :config, :sitemap, :hooks, :helper_modules
    delegate :[], :[]=, :key?, :update, :merge!, to: :config

    def initialize(config: {})
      # Config is a Hash of site wide configuration variables
      @config = config.with_indifferent_access
      # Data contains content and data that needs displaying
      @data = {}.with_indifferent_access
      # Hooks is a hash of hook names and arrays of blocks registered to be
      # executed when those hooks are reached
      @hooks = {}.with_indifferent_access
      # Sitemap is a hash representing the documents in the site that will be
      # processed by Builder. No indifferent access since the keys are all
      # file paths.
      @sitemap = {}
      # Helpers is an array of Modules that will get mixed into the template
      # context and with the current Config instance
      @helper_modules = []
    end

    # Add a page to the sitemap
    def add_page(path, kwargs)
      @sitemap[path] = kwargs.with_indifferent_access
      self
    end

    # Add or replace data
    def add_data(key, val)
      @data[key] = val.is_a?(Hash) ? val.with_indifferent_access : val
      self
    end

    # Set a global config option
    def set(key, val)
      @config[key] = val.is_a?(Hash) ? val.with_indifferent_access : val
      self
    end

    # Set a global config option if it isn't already set
    def set_default(key, val)
      set(key, val) unless @config.key?(key)
    end

    # Add code to run after data is loaded or reloaded. Is also run after object
    # is created.
    def after_load_data(&blk)
      hook(:after_load_data, &blk)
      self
    end

    # Add a block to a hook name
    def hook(name, &blk)
      @hooks[name.to_sym] ||= []
      @hooks[name.to_sym] << blk
      self
    end

    # Add helper modules or define helpers in a block
    def helpers(*mods, type: nil, &blk)
      new_helpers = []
      # loop over the list of arguments, making sure they're all modules, and
      # then add them to the list of new helpers
      mods.each do |mod|
        unless mod.is_a?(Module)
          raise ArgumentError, 'Helpers must be defined in a module or block'
        end

        new_helpers << mod
      end
      # if block is given, turn it into a module and add it to the helpers list
      new_helpers << Module.new(&blk) if blk

      if new_helpers.present?
        # extend the current Config instance with the helpers, making them available
        # to the rest of the configuration block
        extend(*new_helpers.reverse) if type.nil? || type.to_sym == :config
        # add our new helpers to our array of all helpers
        @helper_modules += new_helpers if type.nil? || type.to_sym == :template
      end

      self
    end

    def respond_to_missing?(sym, *)
      config.key?(sym) || super
    end

    # Treat config options as local
    def method_missing(sym)
      return config[sym] if config.key?(sym)

      super
    end
  end

  # Templates are rendered using this class. Templates are effectively treated
  # as methods of this class when rendered. So any methods or attributes exposed
  # in this class are available inside of templates.
  class TemplateContext
    attr_accessor :page
    delegate :data, :sitemap, :config, to: :@config_obj

    def initialize(config)
      # Config is our Builder config object
      @config_obj = config
      # Page is a hash representing the page. Is the same as whats in sitemap
      # for a given page path
      @page = {}
      # Locals is a hash thats used to resolve missing methods, making them
      # seem like local variables
      @locals = {}
    end

    # Render any given template and return as a string. Can be used to render
    # partials.
    def render(template_path, locals = {})
      old_locals = @locals
      @locals = locals.with_indifferent_access
      erb = VizBuilder.erb_new(template_path)
      ret = erb.result(binding)
      @locals = old_locals
      ret
    end

    # Load the content of a pre-rendered file and return it
    def include_file(filepath)
      content = File.read(filepath)
      mime = MimeMagic.by_path(filepath)
      if mime.text?
        return content
      elsif mime.image?
        return Base64.strict_encode64(content)
      else
        raise "File '${filepath}' of type '${mime}' can't be included as text"
      end
    end

    # Looks for an invoked method name in locals then in config, so locals
    # and config vars can be used as if they're local vars in the template
    def method_missing(sym)
      return @locals[sym] if @locals.key?(sym)
      return config[sym] if config.key?(sym)
      super
    end

    def respond_to_missing?(sym, *)
      @locals.key?(sym) || config.key?(sym) || super
    end
  end

  # HELPERS

  # The Helpers module holds methods that are added to both the config and
  # template contexts. So you can use these in the config block and in
  # templates.
  module Helpers
    # Are we running as a server?
    def server?
      config[:mode] == :server
    end

    # Are we building this app out?
    def build?
      config[:mode] == :build
    end

    # Is this in production?
    def production?
      config[:target] == :production
    end

    # Is this in development?
    def development?
      config[:target] == :development
    end
  end

  # The TemplateHelpers modules hold methods that are added to the template
  # context only. So you can only use these in templates.
  module TemplateHelpers
    # Get the full URL to the root of this site
    def http_prefix
      prefix = config[:http_prefix] || '/'
      prefix += '/' unless prefix =~ %r{/$}
      prefix
    end

    # Get the full URL to the root of where assets are stored
    def asset_http_prefix
      prefix = config[:asset_http_prefix] || http_prefix
      prefix += '/' unless prefix =~ %r{/$}
      prefix
    end

    # Generate and return the URL to any asset
    def asset_path(*args)
      path = args.join('/')
      page = sitemap[path]
      if production?
        raise "Missing asset #{path}" if page.blank?
        if page[:digest] == true
          raise "Missing digest for #{path}" if page[:digest_path].blank?
          path = page[:digest_path]
        end
      end
      asset_http_prefix + path
    end

    # Generate and return the URL to any page
    def canonical_url(*args)
      http_prefix + args.join('/')
    end
  end

  # The ConfigHelpers modules hold methods that are added to the config block
  # context only. So you can only use these with the config object instance.
  module ConfigHelpers; end

  private

  # Generate one page from the sitemap and save to `build/`
  def build_page(path, silent: false)
    page = sitemap[path]
    out_fname = File.join(BUILD_DIR, path)
    puts "Rendering #{out_fname}..." unless silent

    # Check page data for info on how to build this path
    if page['template'].present?
      ctx = TemplateContext.new(@config)
      ctx.page = page
      # Check if we have a layout defined, use it
      layout = ctx.page.key?('layout') ? ctx.page['layout'] : config['layout']

      # Make sure to render the template inside the layout render so code in the
      # erb layout and template are executed in a sensible order.
      content =
        if layout.present?
          ctx.render(layout) { ctx.render(ctx.page['template']) }
        else
          ctx.render(ctx.page['template'])
        end
    elsif page['json'].present?
      content = page['json'].to_json
    elsif page['file'].present?
      content = File.read(page['file'])
    else
      raise(
        ArgumentError,
        "Page '#{path}' missing one of required attributes: 'template', 'json', 'file'."
      )
    end

    # If page data includes a digest flag, add sha1 digest to output filename
    if page['digest'] == true
      ext = VizBuilder.fullextname(path)
      fname = File.basename(path, ext)
      dir = File.dirname(path)
      digest = Digest::SHA1.hexdigest(content)
      digest_fname = "#{fname}-#{digest}#{ext}"
      page['digest_path'] = "#{dir}/#{digest_fname}"
      out_fname = File.join(BUILD_DIR, dir, digest_fname)
    end

    FileUtils.mkdir_p(File.dirname(out_fname))
    File.write(out_fname, content)
    content
  end

  # Read json and yaml files from `data/` and load them into a Hash using the
  # basename of the file names.
  def load_data
    data = {}.with_indifferent_access

    %w[.json .yaml].each do |ext|
      Dir.glob("#{DATA_DIR}/*#{ext}") do |fname|
        key = File.basename(fname, ext).to_sym
        puts "Loading data[:#{key}] from #{fname}..."
        data[key] =
          if ext == '.json'
            JSON.parse(File.read(fname))
          else
            Psych.parse(fname)
          end
      end
    end

    data
  end

  # Execute all blocks in config registered with the given hook name.
  def run_hook!(name)
    return unless hooks[name.to_sym]
    hooks[name.to_sym].each { |blk| config.instance_exec(&blk) }
  end

  # Find prebuilt assets and add them to the sitemap
  def index_prebuilt!
    Dir.glob("#{PREBUILT_DIR}/**/[^_]*.*") do |filename|
      config.add_page(filename.sub("#{PREBUILT_DIR}/", ''), file: filename, digest: true)
    end
  end
end
