# frozen_string_literal: true
require 'set'
require 'shellwords'

module Sprockets
  # The `DirectiveProcessor` is responsible for parsing and evaluating
  # directive comments in a source file.
  #
  # A directive comment starts with a comment prefix, followed by an "=",
  # then the directive name, then any arguments.
  #
  #     // JavaScript
  #     //= require "foo"
  #
  #     # CoffeeScript
  #     #= require "bar"
  #
  #     /* CSS
  #      *= require "baz"
  #      */
  #
  # This makes it possible to disable or modify the processor to do whatever
  # you'd like. You could add your own custom directives or invent your own
  # directive syntax.
  #
  # `Environment#processors` includes `DirectiveProcessor` by default.
  #
  # To remove the processor entirely:
  #
  #     env.unregister_processor('text/css', Sprockets::DirectiveProcessor)
  #     env.unregister_processor('application/javascript', Sprockets::DirectiveProcessor)
  #
  # Then inject your own preprocessor:
  #
  #     env.register_processor('text/css', MyProcessor)
  #
  class DirectiveProcessor
    # Directives are denoted by a `=` followed by the name, then
    # argument list.
    #
    # A few different styles are allowed:
    #
    #     // =require foo
    #     //= require foo
    #     //= require "foo"
    #
    DIRECTIVE_PATTERN = /
      ^ \W* = \s* (\w+.*?) (\*\/)? $
    /x

    def self.instance
      # Default to C comment styles
      @instance ||= new(comments: ["//", ["/*", "*/"]])
    end

    def self.call(input)
      instance.call(input)
    end

    def initialize(comments: [])
      @header_pattern = compile_header_pattern(Array(comments))
    end

    def call(input)
      dup._call(input)
    end

    def _call(input)
      @environment  = input[:environment]
      @uri          = input[:uri]
      @filename     = input[:filename]
      @dirname      = File.dirname(@filename)
      # If loading a source map file like `application.js.map` resolve
      # dependencies using `.js` instead of `.js.map`
      @content_type = SourceMapProcessor.original_content_type(input[:content_type], error_when_not_found: false)
      @required     = Set.new(input[:metadata][:required])
      @stubbed      = Set.new(input[:metadata][:stubbed])
      @links        = Set.new(input[:metadata][:links])
      @dependencies = Set.new(input[:metadata][:dependencies])
      @to_link      = Set.new
      @to_load      = Set.new

      data, directives = process_source(input[:data])
      process_directives(directives)

      {
        data:         data,
        required:     @required,
        stubbed:      @stubbed,
        links:        @links,
        to_load:      @to_load,
        to_link:      @to_link,
        dependencies: @dependencies
      }
    end

    protected
      # Directives will only be picked up if they are in the header
      # of the source file. C style (/* */), JavaScript (//), and
      # Ruby (#) comments are supported.
      #
      # Directives in comments after the first non-whitespace line
      # of code will not be processed.
      def compile_header_pattern(comments)
        re = comments.map { |c|
          case c
          when String
            "(?:#{Regexp.escape(c)}.*\\n?)+"
          when Array
            "(?:#{Regexp.escape(c[0])}(?m:.*?)#{Regexp.escape(c[1])})"
          else
            raise TypeError, "unknown comment type: #{c.class}"
          end
        }.join("|")
        Regexp.compile("\\A(?:(?m:\\s*)(?:#{re}))+")
      end

      def process_source(source)
        header = source[@header_pattern, 0] || ""
        body   = $' || source

        header, directives = extract_directives(header)

        data = +""
        data.force_encoding(body.encoding)
        data << header unless header.empty?
        data << body
        # Ensure body ends in a new line
        data << "\n" if data.length > 0 && data[-1] != "\n"

        return data, directives
      end

      # Returns an Array of directive structures. Each structure
      # is an Array with the line number as the first element, the
      # directive name as the second element, followed by any
      # arguments.
      #
      #     [[1, "require", "foo"], [2, "require", "bar"]]
      #
      def extract_directives(header)
        processed_header = +""
        directives = []

        header.lines.each_with_index do |line, index|
          if directive = line[DIRECTIVE_PATTERN, 1]
            name, *args = Shellwords.shellwords(directive)
            if respond_to?("process_#{name}_directive", true)
              directives << [index + 1, name, *args]
              # Replace directive line with a clean break
              line = "\n"
            end
          end
          processed_header << line
        end

        processed_header.chomp!
        # Ensure header ends in a new line like before it was processed
        processed_header << "\n" if processed_header.length > 0 && header[-1] == "\n"

        return processed_header, directives
      end

      # Gathers comment directives in the source and processes them.
      # Any directive method matching `process_*_directive` will
      # automatically be available. This makes it easy to extend the
      # processor.
      #
      # To implement a custom directive called `require_glob`, subclass
      # `Sprockets::DirectiveProcessor`, then add a method called
      # `process_require_glob_directive`.
      #
      #     class DirectiveProcessor < Sprockets::DirectiveProcessor
      #       def process_require_glob_directive(glob)
      #         Dir["#{dirname}/#{glob}"].sort.each do |filename|
      #           require(filename)
      #         end
      #       end
      #     end
      #
      # Replace the current processor on the environment with your own:
      #
      #     env.unregister_processor('text/css', Sprockets::DirectiveProcessor)
      #     env.register_processor('text/css', DirectiveProcessor)
      #
      def process_directives(directives)
        directives.each do |line_number, name, *args|
          begin
            send("process_#{name}_directive", *args)
          rescue Exception => e
            e.set_backtrace(["#{@filename}:#{line_number}"] + e.backtrace)
            raise e
          end
        end
      end

      # The `require` directive functions similar to Ruby's own `require`.
      # It provides a way to declare a dependency on a file in your path
      # and ensures it's only loaded once before the source file.
      #
      # `require` works with files in the environment path:
      #
      #     //= require "foo.js"
      #
      # Extensions are optional. If your source file is ".js", it
      # assumes you are requiring another ".js".
      #
      #     //= require "foo"
      #
      # Relative paths work too. Use a leading `./` to denote a relative
      # path:
      #
      #     //= require "./bar"
      #
      def process_require_directive(path)
        @required << resolve(path, accept: @content_type, pipeline: :self)
      end

      # `require_self` causes the body of the current file to be inserted
      # before any subsequent `require` directives. Useful in CSS files, where
      # it's common for the index file to contain global styles that need to
      # be defined before other dependencies are loaded.
      #
      #     /*= require "reset"
      #      *= require_self
      #      *= require_tree .
      #      */
      #
      def process_require_self_directive
        if @required.include?(@uri)
          raise ArgumentError, "require_self can only be called once per source file"
        end
        @required << @uri
      end

      # `require_directory` requires all the files inside a single
      # directory. It's similar to `path/*` since it does not follow
      # nested directories.
      #
      #     //= require_directory "./javascripts"
      #
      def process_require_directory_directive(path = ".")
        path = expand_relative_dirname(:require_directory, path)
        require_paths(*@environment.stat_directory_with_dependencies(path))
      end

      # `require_tree` requires all the nested files in a directory.
      # Its glob equivalent is `path/**/*`.
      #
      #     //= require_tree "./public"
      #
      def process_require_tree_directive(path = ".")
        path = expand_relative_dirname(:require_tree, path)
        require_paths(*@environment.stat_sorted_tree_with_dependencies(path))
      end

      # Allows you to state a dependency on a file without
      # including it.
      #
      # This is used for caching purposes. Any changes made to
      # the dependency file will invalidate the cache of the
      # source file.
      #
      # This is useful if you are using ERB and File.read to pull
      # in contents from another file.
      #
      #     //= depend_on "foo.png"
      #
      def process_depend_on_directive(path)
        resolve(path)
      end

      # Allows you to state a dependency on an asset without including
      # it.
      #
      # This is used for caching purposes. Any changes that would
      # invalidate the asset dependency will invalidate the cache of
      # the source file.
      #
      # Unlike `depend_on`, the path must be a requirable asset.
      #
      #     //= depend_on_asset "bar.js"
      #
      def process_depend_on_asset_directive(path)
        to_load(resolve(path))
      end

      # Allows you to state a dependency on a relative directory
      # without including it.
      #
      # This is used for caching purposes. Any changes made to
      # the dependency directory will invalidate the cache of the
      # source file.
      #
      # This is useful if you are using ERB and File.read to pull
      # in contents from multiple files in a directory.
      #
      #     //= depend_on_directory ./data
      #
      def process_depend_on_directory_directive(path = ".", accept = nil)
        path = expand_relative_dirname(:depend_on_directory, path)
        accept = expand_accept_shorthand(accept)
        resolve_paths(*@environment.stat_directory_with_dependencies(path), accept: accept)
      end

      # Allows dependency to be excluded from the asset bundle.
      #
      # The `path` must be a valid asset and may or may not already
      # be part of the bundle. Once stubbed, it is blacklisted and
      # can't be brought back by any other `require`.
      #
      #     //= stub "jquery"
      #
      def process_stub_directive(path)
        @stubbed << resolve(path, accept: @content_type, pipeline: :self)
      end

      # Declares a linked dependency on the target asset.
      #
      # The `path` must be a valid asset and should not already be part of the
      # bundle. Any linked assets will automatically be compiled along with the
      # current.
      #
      #   /*= link "logo.png" */
      #
      def process_link_directive(path)
        uri = to_load(resolve(path))
        @to_link << uri
      end

      # `link_directory` links all the files inside a single
      # directory. It's similar to `path/*` since it does not follow
      # nested directories.
      #
      #     //= link_directory "./fonts"
      #
      # Use caution when linking against JS or CSS assets. Include an explicit
      # extension or content type in these cases.
      #
      #     //= link_directory "./scripts" .js
      #
      def process_link_directory_directive(path = ".", accept = nil)
        path = expand_relative_dirname(:link_directory, path)
        accept = expand_accept_shorthand(accept)
        link_paths(*@environment.stat_directory_with_dependencies(path), accept)
      end

      # `link_tree` links all the nested files in a directory.
      # Its glob equivalent is `path/**/*`.
      #
      #     //= link_tree "./images"
      #
      # Use caution when linking against JS or CSS assets. Include an explicit
      # extension or content type in these cases.
      #
      #     //= link_tree "./styles" .css
      #
      def process_link_tree_directive(path = ".", accept = nil)
        path = expand_relative_dirname(:link_tree, path)
        accept = expand_accept_shorthand(accept)
        link_paths(*@environment.stat_sorted_tree_with_dependencies(path), accept)
      end

    private
      def expand_accept_shorthand(accept)
        if accept.nil?
          nil
        elsif accept.include?("/")
          accept
        elsif accept.start_with?(".")
          @environment.mime_exts[accept]
        else
          @environment.mime_exts[".#{accept}"]
        end
      end

      def require_paths(paths, deps)
        resolve_paths(paths, deps, accept: @content_type, pipeline: :self) do |uri|
          @required << uri
        end
      end

      def link_paths(paths, deps, accept)
        resolve_paths(paths, deps, accept: accept) do |uri|
          @to_link << to_load(uri)
        end
      end

      def resolve_paths(paths, deps, **kargs)
        @dependencies.merge(deps)
        paths.each do |subpath, stat|
          next if subpath == @filename || stat.directory?
          uri, deps = @environment.resolve(subpath, **kargs)
          @dependencies.merge(deps)
          yield uri if uri && block_given?
        end
      end

      def expand_relative_dirname(directive, path)
        if @environment.relative_path?(path)
          path = File.expand_path(path, @dirname)
          stat = @environment.stat(path)

          if stat && stat.directory?
            path
          else
            raise ArgumentError, "#{directive} argument must be a directory"
          end
        else
          # The path must be relative and start with a `./`.
          raise ArgumentError, "#{directive} argument must be a relative path"
        end
      end

      def to_load(uri)
        @to_load << uri
        uri
      end

      def resolve(path, **kargs)
        # Prevent absolute paths in directives
        if @environment.absolute_path?(path)
          raise FileOutsidePaths, "can't require absolute file: #{path}"
        end

        kargs[:base_path] = @dirname
        uri, deps = @environment.resolve!(path, **kargs)
        @dependencies.merge(deps)
        uri
      end
  end
end
