usage 'compile [options]'
summary 'compile items of this site'
description <<-EOS
Compile all items of the current site.

The compile command will show all items of the site as they are processed. The time spent compiling the item will be printed, as well as a status message, which can be one of the following:

CREATED - The compiled item did not yet exist and has been created

UPDATED - The compiled item did already exist and has been modified

IDENTICAL - The item was deemed outdated and has been recompiled, but the compiled version turned out to be identical to the already existing version

SKIP - The item was deemed not outdated and was therefore not recompiled

EOS
flag nil, :profile, 'profile compilation' if Nanoc::Feature.enabled?('PROFILER')

module Nanoc::CLI::Commands
  class Compile < ::Nanoc::CLI::CommandRunner
    # Listens to compilation events and reacts to them. This abstract class
    # does not have a real implementation; subclasses should override {#start}
    # and set up notifications to listen to.
    #
    # @abstract Subclasses must override {#start} and may override {#stop}.
    class Listener
      def initialize(*)
      end

      # @param [Nanoc::CLI::CommandRunner] command_runner The command runner for this listener
      #
      # @return [Boolean] true if this listener should be enabled for the given command runner, false otherwise
      #
      # @abstract Returns `true` by default, but subclasses may override this.
      def self.enable_for?(command_runner) # rubocop:disable Lint/UnusedMethodArgument
        true
      end

      # Starts the listener. Subclasses should override this method and set up listener notifications.
      #
      # @return [void]
      #
      # @abstract
      def start
        raise NotImplementedError, 'Subclasses of Listener should implement #start'
      end

      # Stops the listener. The default implementation removes self from all notification center observers.
      #
      # @return [void]
      def stop
      end

      # @api private
      def start_safely
        start
        @_started = true
      end

      # @api private
      def stop_safely
        stop if @_started
        @_started = false
      end
    end

    # Generates diffs for every output file written
    class DiffGenerator < Listener
      # @see Listener#enable_for?
      def self.enable_for?(command_runner)
        command_runner.site.config[:enable_output_diff]
      end

      # @see Listener#start
      def start
        require 'tempfile'
        setup_diffs
        old_contents = {}
        Nanoc::Int::NotificationCenter.on(:will_write_rep, self) do |rep, path|
          old_contents[rep] = File.file?(path) ? File.read(path) : nil
        end
        Nanoc::Int::NotificationCenter.on(:rep_written, self) do |rep, path, _is_created, _is_modified|
          unless rep.binary?
            new_contents = File.file?(path) ? File.read(path) : nil
            if old_contents[rep] && new_contents
              generate_diff_for(path, old_contents[rep], new_contents)
            end
            old_contents.delete(rep)
          end
        end
      end

      # @see Listener#stop
      def stop
        super

        Nanoc::Int::NotificationCenter.remove(:will_write_rep, self)
        Nanoc::Int::NotificationCenter.remove(:rep_written, self)

        teardown_diffs
      end

      protected

      def setup_diffs
        @diff_lock    = Mutex.new
        @diff_threads = []
        FileUtils.rm('output.diff') if File.file?('output.diff')
      end

      def teardown_diffs
        @diff_threads.each(&:join)
      end

      def generate_diff_for(path, old_content, new_content)
        return if old_content == new_content

        @diff_threads << Thread.new do
          # Generate diff
          diff = diff_strings(old_content, new_content)
          diff.sub!(/^--- .*/,    '--- ' + path)
          diff.sub!(/^\+\+\+ .*/, '+++ ' + path)

          # Write diff
          @diff_lock.synchronize do
            File.open('output.diff', 'a') { |io| io.write(diff) }
          end
        end
      end

      def diff_strings(a, b)
        require 'open3'

        # Create files
        Tempfile.open('old') do |old_file|
          Tempfile.open('new') do |new_file|
            # Write files
            old_file.write(a)
            old_file.flush
            new_file.write(b)
            new_file.flush

            # Diff
            cmd = ['diff', '-u', old_file.path, new_file.path]
            Open3.popen3(*cmd) do |_stdin, stdout, _stderr|
              result = stdout.read
              return (result == '' ? nil : result)
            end
          end
        end
      rescue Errno::ENOENT
        warn 'Failed to run `diff`, so no diff with the previously compiled ' \
             'content will be available.'
        nil
      end
    end

    # Records the time spent per filter and per item representation
    class TimingRecorder < Listener
      # @see Listener#enable_for?
      def self.enable_for?(command_runner)
        command_runner.options.fetch(:verbose, false)
      end

      # @param [Enumerable<Nanoc::Int::ItemRep>] reps
      def initialize(reps:)
        @times = {}

        @reps = reps
      end

      # @see Listener#start
      def start
        Nanoc::Int::NotificationCenter.on(:filtering_started) do |_rep, filter_name|
          @times[filter_name] ||= []
          @times[filter_name] << { start: Time.now }
        end
        Nanoc::Int::NotificationCenter.on(:filtering_ended) do |_rep, filter_name|
          @times[filter_name].last[:stop] = Time.now
        end
      end

      # @see Listener#stop
      def stop
        print_profiling_feedback
        super
      end

      protected

      def print_profiling_feedback
        # Get max filter length
        max_filter_name_length = durations_per_filter.keys.map { |k| k.to_s.size }.max
        return if max_filter_name_length.nil?

        # Print warning if necessary
        if @reps.any? { |r| !r.compiled? }
          $stderr.puts
          $stderr.puts 'Warning: profiling information may not be accurate because ' \
                       'some items were not compiled.'
        end

        # Print header
        puts
        puts ' ' * max_filter_name_length + ' | count    min    avg    max     tot'
        puts '-' * max_filter_name_length + '-+-----------------------------------'

        durations_per_filter.to_a.sort_by { |r| r[1] }.each do |row|
          print_row(row, max_filter_name_length)
        end
      end

      def print_row(row, length)
        # Extract data
        filter_name, samples = *row

        # Calculate stats
        count = samples.size
        min   = samples.min
        tot   = samples.reduce(0) { |a, e| a + e }
        avg   = tot / count
        max   = samples.max

        # Format stats
        count = format('%4d',   count)
        min   = format('%4.2f', min)
        avg   = format('%4.2f', avg)
        max   = format('%4.2f', max)
        tot   = format('%5.2f', tot)

        # Output stats
        key = format("%#{length}s", filter_name)
        puts "#{key} |  #{count}  #{min}s  #{avg}s  #{max}s  #{tot}s"
      end

      def durations_per_filter
        @_durations_per_filter ||= begin
          result = {}
          @times.keys.each do |filter_name|
            durations = durations_for_filter(filter_name)
            if durations
              result[filter_name] = durations
            end
          end
          result
        end
      end

      def durations_for_filter(filter_name)
        result = []
        @times[filter_name].each do |sample|
          if sample[:start] && sample[:stop]
            result << sample[:stop] - sample[:start]
          end
        end
        result
      end
    end

    # Controls garbage collection so that it only occurs once every 20 items
    class GCController < Listener
      # @see Listener#enable_for?
      def self.enable_for?(_command_runner)
        !ENV.key?('TRAVIS')
      end

      def initialize(*)
        @gc_count = 0
      end

      # @see Listener#start
      def start
        Nanoc::Int::NotificationCenter.on(:compilation_started) do |_rep|
          if @gc_count % 20 == 0
            GC.enable
            GC.start
            GC.disable
          end
          @gc_count += 1
        end
      end

      # @see Listener#stop
      def stop
        super
        GC.enable
      end
    end

    # Prints debug information (compilation started/ended, filtering started/ended, …)
    class DebugPrinter < Listener
      # @see Listener#enable_for?
      def self.enable_for?(command_runner)
        command_runner.debug?
      end

      # @see Listener#start
      def start
        Nanoc::Int::NotificationCenter.on(:compilation_started) do |rep|
          puts "*** Started compilation of #{rep.inspect}"
        end
        Nanoc::Int::NotificationCenter.on(:compilation_ended) do |rep|
          puts "*** Ended compilation of #{rep.inspect}"
          puts
        end
        Nanoc::Int::NotificationCenter.on(:compilation_failed) do |rep, e|
          puts "*** Suspended compilation of #{rep.inspect}: #{e.message}"
        end
        Nanoc::Int::NotificationCenter.on(:cached_content_used) do |rep|
          puts "*** Used cached compiled content for #{rep.inspect} instead of recompiling"
        end
        Nanoc::Int::NotificationCenter.on(:filtering_started) do |rep, filter_name|
          puts "*** Started filtering #{rep.inspect} with #{filter_name}"
        end
        Nanoc::Int::NotificationCenter.on(:filtering_ended) do |rep, filter_name|
          puts "*** Ended filtering #{rep.inspect} with #{filter_name}"
        end
        Nanoc::Int::NotificationCenter.on(:dependency_created) do |src, dst|
          puts "*** Dependency created from #{src.inspect} onto #{dst.inspect}"
        end
      end
    end

    # Prints file actions (created, updated, deleted, identical, skipped)
    class FileActionPrinter < Listener
      def initialize(reps:)
        @start_times = {}

        @reps = reps
      end

      # @see Listener#start
      def start
        Nanoc::Int::NotificationCenter.on(:compilation_started) do |rep|
          @start_times[rep.raw_path] = Time.now
        end
        Nanoc::Int::NotificationCenter.on(:rep_written) do |_rep, path, is_created, is_modified|
          duration = path && @start_times[path] ? Time.now - @start_times[path] : nil
          action =
            if is_created then :create
            elsif is_modified then :update
            else :identical
            end
          level =
            if is_created then :high
            elsif is_modified then :high
            else :low
            end
          log(level, action, path, duration)
        end
      end

      # @see Listener#stop
      def stop
        super
        @reps.select { |r| !r.compiled? }.each do |rep|
          rep.raw_paths.each do |_snapshot_name, raw_path|
            log(:low, :skip, raw_path, nil)
          end
        end
      end

      private

      def log(level, action, path, duration)
        Nanoc::CLI::Logger.instance.file(level, action, path, duration)
      end
    end

    # Records a profile using StackProf
    class StackProfProfiler < Listener
      PROFILE_FILE = 'tmp/stackprof_profile'.freeze

      # @see Listener#enable_for?
      def self.enable_for?(command_runner)
        command_runner.options.fetch(:profile, false)
      end

      # @see Listener#start
      def start
        require 'stackprof'
        StackProf.start(mode: :cpu)
      end

      # @see Listener#stop
      def stop
        StackProf.stop
        StackProf.results(PROFILE_FILE)
      end
    end

    attr_accessor :listener_classes

    def initialize(options, arguments, command)
      super
      @listener_classes = default_listener_classes
    end

    def run
      time_before = Time.now

      load_site

      puts 'Compiling site…'
      run_listeners_while do
        site.compile
        prune
      end

      time_after = Time.now
      puts
      puts "Site compiled in #{format('%.2f', time_after - time_before)}s."
    end

    protected

    def prune
      if site.config[:prune][:auto_prune]
        Nanoc::Extra::Pruner.new(site, exclude: prune_config_exclude).run
      end
    end

    def default_listener_classes
      [
        Nanoc::CLI::Commands::Compile::DiffGenerator,
        Nanoc::CLI::Commands::Compile::DebugPrinter,
        Nanoc::CLI::Commands::Compile::TimingRecorder,
        Nanoc::CLI::Commands::Compile::GCController,
        Nanoc::CLI::Commands::Compile::FileActionPrinter,
        Nanoc::CLI::Commands::Compile::StackProfProfiler,
      ]
    end

    def setup_listeners
      @listeners =
        @listener_classes
        .select { |klass| klass.enable_for?(self) }
        .map    { |klass| klass.new(reps: reps) }

      @listeners.each(&:start_safely)
    end

    def listeners
      @listeners
    end

    def run_listeners_while
      setup_listeners
      yield
    ensure
      teardown_listeners
    end

    def teardown_listeners
      @listeners.each(&:stop_safely)
    end

    def reps
      site.compiler.reps
    end

    def prune_config
      site.config[:prune] || {}
    end

    def prune_config_exclude
      prune_config[:exclude] || {}
    end
  end
end

runner Nanoc::CLI::Commands::Compile
