require "../protocol"

module Lattice
  # Delegates document formatting to the Crystal compiler's built-in formatter
  # (`crystal tool format`) and converts the result into an array of
  # `::LSP::TextEdit` values that replace the entire document content.
  #
  # Range formatting is supported by extracting the requested line range,
  # formatting only that slice, and emitting a single edit covering those lines.
  # This is a best-effort approximation; `crystal tool format` does not accept
  # a line range, so the slice is formatted in isolation and re-inserted.
  #
  # If the `crystal` binary is unavailable the provider returns an empty array
  # so the editor falls back gracefully.
  class FormattingProvider
    # Format the entire document and return the minimal replacement edit.
    # Returns an empty array if the source is already formatted or if
    # formatting fails.
    def format(uri : String, source : String) : Array(::LSP::TextEdit)
      formatted = run_formatter(source)
      return [] of ::LSP::TextEdit if formatted.nil? || formatted == source

      lines = source.split('\n')
      last_line = lines.size - 1
      last_char = lines.last.size

      full_range = ::LSP::Range.new(
        ::LSP::Position.new(0, 0),
        ::LSP::Position.new(last_line, last_char),
      )
      [::LSP::TextEdit.new(full_range, formatted)]
    end

    # Format only the lines covered by *range* and return the replacement edit.
    # Returns an empty array if formatting fails or produces no change.
    def format_range(uri : String, source : String, range : ::LSP::Range) : Array(::LSP::TextEdit)
      lines = source.split('\n')
      start_line = range.start.line.clamp(0, lines.size - 1)
      end_line   = range.ending.line.clamp(0, lines.size - 1)

      slice_lines = lines[start_line..end_line]
      slice_source = slice_lines.join('\n')

      formatted_slice = run_formatter(slice_source)
      return [] of ::LSP::TextEdit if formatted_slice.nil? || formatted_slice == slice_source

      # Trim a trailing newline the formatter may add, to avoid inserting a
      # blank line after the selected block.
      formatted_slice = formatted_slice.rstrip('\n')

      slice_range = ::LSP::Range.new(
        ::LSP::Position.new(start_line, 0),
        ::LSP::Position.new(end_line, lines[end_line].size),
      )
      [::LSP::TextEdit.new(slice_range, formatted_slice)]
    end

    # ---------------------------------------------------------------------------
    # Private helpers
    # ---------------------------------------------------------------------------

    # Pipe *source* through `crystal tool format -` (stdin/stdout mode).
    # Returns the formatted string, or nil on any error.
    private def run_formatter(source : String) : String?
      input  = IO::Memory.new(source)
      output = IO::Memory.new
      error  = IO::Memory.new

      status = Process.run(
        "crystal",
        ["tool", "format", "-"],
        input: input,
        output: output,
        error: error,
      )

      return nil unless status.success?

      result = output.to_s
      result.empty? ? nil : result
    rescue
      nil
    end
  end
end

module Lattice
  # OnTypeFormattingProvider handles `textDocument/onTypeFormatting`.
  #
  # Crystal's block-closing keywords (`end`, `else`, `elsif`, `rescue`,
  # `ensure`, `when`, `in`) should always be indented to match the opening
  # keyword of their enclosing block.  Editors that send an on-type request
  # when the user finishes typing one of these words will receive a single
  # `TextEdit` that re-indents the current line to the correct column.
  #
  # Algorithm
  # ---------
  # 1. Strip leading whitespace from the just-typed line.
  # 2. If the stripped content does not start with one of the trigger words,
  #    return an empty array (no-op).
  # 3. Walk upward through the preceding lines maintaining a nesting counter.
  #    Increment on block-opening keywords; decrement on `end` / `else` /
  #    `rescue` / `ensure` (which close one level before opening another).
  # 4. The target indentation is the indentation of the matching opener.
  # 5. If the current line's indentation already matches, return an empty array.
  # 6. Otherwise emit a single `TextEdit` replacing the leading whitespace.
  class OnTypeFormattingProvider
    # Keywords that close (and optionally re-open) a block level.
    CLOSERS = %w[end else elsif rescue ensure when in]

    # Keywords that open a new block level when they appear at the start of a
    # logical line (after stripping visibility modifiers and `abstract`).
    OPENERS_RE = /^(?:(?:private|protected|abstract)\s+)*(?:def|class|module|struct|enum|lib|macro|if|unless|case|while|until|begin|do|annotation|fun)\b/

    # Return the indentation edits for the character just typed on *line* at
    # *character* in *source*.  *ch* is the trigger character.
    def edits(
      source : String,
      line : Int32,
      character : Int32,
      ch : String,
    ) : Array(::LSP::TextEdit)
      lines = source.split('\n')
      return [] of ::LSP::TextEdit if line >= lines.size

      current_line = lines[line]
      stripped     = current_line.lstrip

      # Only act when the line consists solely of the trigger word (plus
      # optional trailing content such as a condition after `elsif`).
      keyword = CLOSERS.find { |kw| stripped == kw || stripped.starts_with?("#{kw} ") || stripped.starts_with?("#{kw}\t") }
      return [] of ::LSP::TextEdit unless keyword

      target_indent = compute_target_indent(lines, line, keyword)
      current_indent = current_line[/^\s*/]

      # No change needed.
      return [] of ::LSP::TextEdit if current_indent == target_indent

      # Replace only the leading whitespace on the current line.
      edit_range = ::LSP::Range.new(
        ::LSP::Position.new(line, 0),
        ::LSP::Position.new(line, current_indent.size),
      )
      [::LSP::TextEdit.new(edit_range, target_indent)]
    end

    # ---------------------------------------------------------------------------
    # Private
    # ---------------------------------------------------------------------------

    # Walk upward from *from_line* (exclusive) to find the indentation level
    # that the *keyword* closer should align to.
    private def compute_target_indent(
      lines : Array(String),
      from_line : Int32,
      keyword : String,
    ) : String
      # `end` closes one full level; mid-block keywords (`else`, `elsif`,
      # `rescue`, `ensure`, `when`, `in`) sit at the same level as their opener.
      depth = keyword == "end" ? 1 : 0

      (from_line - 1).downto(0) do |i|
        ln      = lines[i]
        stripped = ln.lstrip
        indent  = ln[/^\s*/]

        next if stripped.empty? || stripped.starts_with?('#')

        # A naked `end` on this line closes a block we haven't opened yet.
        if stripped == "end" || stripped.starts_with?("end ") || stripped.starts_with?("end\t") || stripped.starts_with?("end#")
          depth += 1
          next
        end

        # Mid-block keywords at the same nesting depth as their opener — they
        # increment depth so we skip past the opener on the next iteration.
        if depth == 0 && %w[else elsif rescue ensure when in].any? { |kw|
             stripped == kw || stripped.starts_with?("#{kw} ") || stripped.starts_with?("#{kw}\t")
           }
          depth += 1
          next
        end

        # An opener at this depth is the matching partner.
        if stripped.match(OPENERS_RE) || stripped.starts_with?("do ") || stripped == "do"
          if depth <= 1
            return indent
          end
          depth -= 1
        end
      end

      # Fallback: no opener found — use zero indentation.
      ""
    end
  end
end

module LSP
  # ---------------------------------------------------------------------------
  # Protocol types for textDocument/formatting and textDocument/rangeFormatting
  # ---------------------------------------------------------------------------

  struct FormattingOptions
    include JSON::Serializable

    @[JSON::Field(key: "tabSize")]
    property tab_size : Int32
    @[JSON::Field(key: "insertSpaces")]
    property insert_spaces : Bool

    def initialize(@tab_size : Int32 = 2, @insert_spaces : Bool = true)
    end
  end

  struct DocumentFormattingParams
    include JSON::Serializable

    @[JSON::Field(key: "textDocument")]
    property text_document : TextDocumentIdentifier
    property options : FormattingOptions
  end

  struct DocumentRangeFormattingParams
    include JSON::Serializable

    @[JSON::Field(key: "textDocument")]
    property text_document : TextDocumentIdentifier
    property range : Range
    property options : FormattingOptions
  end

  struct DocumentOnTypeFormattingParams
    include JSON::Serializable

    @[JSON::Field(key: "textDocument")]
    property text_document : TextDocumentIdentifier
    property position : Position
    property ch : String
    property options : FormattingOptions
  end
end
