require 'pathname'

module Sass::Source
  class Map
    # A mapping from one source range to another. Indicates that `input` was
    # compiled to `output`.
    #
    # @!attribute input
    #   @return [Sass::Source::Range] The source range in the input document.
    #
    # @!attribute output
    #   @return [Sass::Source::Range] The source range in the output document.
    class Mapping < Struct.new(:input, :output)
      # @return [String] A string representation of the mapping.
      def inspect
        "#{input.inspect} => #{output.inspect}"
      end
    end

    # The mapping data ordered by the location in the target.
    #
    # @return [Array<Mapping>]
    attr_reader :data

    def initialize
      @data = []
    end

    # Adds a new mapping from one source range to another. Multiple invocations
    # of this method should have each `output` range come after all previous ranges.
    #
    # @param input [Sass::Source::Range]
    #   The source range in the input document.
    # @param output [Sass::Source::Range]
    #   The source range in the output document.
    def add(input, output)
      @data.push(Mapping.new(input, output))
    end

    # Shifts all output source ranges forward one or more lines.
    #
    # @param delta [Fixnum] The number of lines to shift the ranges forward.
    def shift_output_lines(delta)
      return if delta == 0
      @data.each do |m|
        m.output.start_pos.line += delta
        m.output.end_pos.line += delta
      end
    end

    # Shifts any output source ranges that lie on the first line forward one or
    # more characters on that line.
    #
    # @param delta [Fixnum] The number of characters to shift the ranges
    #   forward.
    def shift_output_offsets(delta)
      return if delta == 0
      @data.each do |m|
        break if m.output.start_pos.line > 1
        m.output.start_pos.offset += delta
        m.output.end_pos.offset += delta if m.output.end_pos.line > 1
      end
    end

    # Returns the standard JSON representation of the source map.
    #
    # If the `:css_uri` option isn't specified, the `:css_path` and
    # `:sourcemap_path` options must both be specified. Any options may also be
    # specified alongside the `:css_uri` option. If `:css_uri` isn't specified,
    # it will be inferred from `:css_path` and `:sourcemap_path` using the
    # assumption that the local file system has the same layout as the server.
    #
    # If any source stylesheets use the default filesystem importer, sourcemap
    # generation will fail unless the `:sourcemap_path` option is specified.
    # The layout of the local file system is assumed to be the same as the
    # layout of the server for the purposes of linking to source stylesheets
    # that use the filesystem importer.
    #
    # Regardless of which options are passed to this method, source stylesheets
    # that are imported using a non-default importer will only be linked to in
    # the source map if their importers implement
    # \{Sass::Importers::Base#public\_url\}.
    #
    # @option options :css_uri [String]
    #   The publicly-visible URI of the CSS output file.
    # @option options :css_path [String]
    #   The local path of the CSS output file.
    # @option options :sourcemap_path [String]
    #   The (eventual) local path of the sourcemap file.
    # @return [String] The JSON string.
    # @raise [ArgumentError] If neither `:css_uri` nor `:css_path` and
    #   `:sourcemap_path` are specified.
    # @comment
    #   rubocop:disable MethodLength
    def to_json(options)
      css_uri, css_path, sourcemap_path =
        [:css_uri, :css_path, :sourcemap_path].map {|o| options[o]}
      unless css_uri || (css_path && sourcemap_path)
        raise ArgumentError.new("Sass::Source::Map#to_json requires either " +
          "the :css_uri option or both the :css_path and :soucemap_path options.")
      end
      css_path &&= Pathname.pwd.join(Pathname.new(css_path)).cleanpath
      sourcemap_path &&= Pathname.pwd.join(Pathname.new(sourcemap_path)).cleanpath
      css_uri ||= css_path.relative_path_from(sourcemap_path.dirname).to_s

      result = "{\n"
      write_json_field(result, "version", 3, true)

      source_uri_to_id = {}
      id_to_source_uri = {}
      next_source_id = 0
      line_data = []
      segment_data_for_line = []
      no_public_url = Set.new

      # These track data necessary for the delta coding.
      previous_target_line = nil
      previous_target_offset = 1
      previous_source_line = 1
      previous_source_offset = 1
      previous_source_id = 0

      @data.each do |m|
        file, importer = m.input.file, m.input.importer
        unless (source_uri = importer && importer.public_url(file))
          if importer.is_a?(Sass::Importers::Filesystem) && sourcemap_path
            file_path = Pathname.new(importer.root).join(file)
            source_uri = file_path.relative_path_from(sourcemap_path.dirname).to_s
          elsif no_public_url.include?(file)
            next
          else
            no_public_url << file
            Sass::Util.sass_warn <<WARNING
WARNING: Couldn't determine public URL for "#{file}" while generating sourcemap.
  Without a public URL, there's nothing for the source map to link to.
  Custom importers should define the #public_url method.
WARNING
            next
          end
        end

        current_source_id = source_uri_to_id[source_uri]
        unless current_source_id
          current_source_id = next_source_id
          next_source_id += 1

          source_uri_to_id[source_uri] = current_source_id
          id_to_source_uri[current_source_id] = source_uri
        end

        [
          [m.input.start_pos, m.output.start_pos],
          [m.input.end_pos, m.output.end_pos]
        ].each do |source_pos, target_pos|
          if previous_target_line != target_pos.line
            line_data.push(segment_data_for_line.join(",")) unless segment_data_for_line.empty?
            (target_pos.line - 1 - (previous_target_line || 0)).times {line_data.push("")}
            previous_target_line = target_pos.line
            previous_target_offset = 1
            segment_data_for_line = []
          end

          # `segment` is a data chunk for a single position mapping.
          segment = ""

          # Field 1: zero-based starting offset.
          segment << Sass::Util.encode_vlq(target_pos.offset - previous_target_offset)
          previous_target_offset = target_pos.offset

          # Field 2: zero-based index into the "sources" list.
          segment << Sass::Util.encode_vlq(current_source_id - previous_source_id)
          previous_source_id = current_source_id

          # Field 3: zero-based starting line in the original source.
          segment << Sass::Util.encode_vlq(source_pos.line - previous_source_line)
          previous_source_line = source_pos.line

          # Field 4: zero-based starting offset in the original source.
          segment << Sass::Util.encode_vlq(source_pos.offset - previous_source_offset)
          previous_source_offset = source_pos.offset

          segment_data_for_line.push(segment)

          previous_target_line = target_pos.line
        end
      end
      line_data.push(segment_data_for_line.join(","))
      write_json_field(result, "mappings", line_data.join(";"))

      source_names = []
      (0...next_source_id).each {|id| source_names.push(id_to_source_uri[id].to_s)}
      write_json_field(result, "sources", source_names)
      write_json_field(result, "file", css_uri)

      result << "\n}"
      result
    end
    # @comment
    #   rubocop:enable MethodLength

    private

    def write_json_field(out, name, value, is_first = false)
      out << (is_first ? "" : ",\n") <<
        "\"" <<
        Sass::Util.json_escape_string(name) <<
        "\": " <<
        Sass::Util.json_value_of(value)
    end
  end
end
