require "../protocol"
require "compiler/crystal/syntax"

module Lattice
  # Provides all Phase 4 navigation and visual LSP handlers:
  #
  # - `textDocument/documentHighlight`  — highlight every reference to the
  #   symbol under the cursor within the current file, scope-aware via the PSI
  #   tree.  Handles Crystal's `?` and `!` method-suffix conventions.
  # - `textDocument/foldingRange`       — foldable regions computed from the
  #   Crystal AST via a PSI walk; immune to heredocs and one-liner modifiers.
  # - `textDocument/selectionRange`     — expand selection by walking the PSI
  #   ancestor chain: token → expression → block → method → class → file.
  # - `textDocument/linkedEditingRange` — linked rename for the `class Foo`
  #   / `end` name pair (Crystal has no explicit closing name, so we link the
  #   opening keyword name only when a second identical token appears on the
  #   same line in a pattern like `# end Foo`).
  # - `textDocument/documentLink`       — `require "..."` strings as clickable
  #   links resolved to their file URIs.
  # - `textDocument/callHierarchy`      — prepare + incoming/outgoing calls
  #   built from the workspace index call graph.
  # - `textDocument/typeHierarchy`      — prepare + supertypes/subtypes built
  #   from the index `< Parent` and `include Module` relations.
  module NavigationProviders
    # -------------------------------------------------------------------------
    # Document Highlight  (PSI-based, scope-aware)
    # -------------------------------------------------------------------------

    # Return highlight ranges for every in-file occurrence of the symbol at
    # *position* that shares the same binding scope, using the Crystal AST.
    #
    # The algorithm:
    # 1. Parse the source into an AST.
    # 2. Collect every `Var`, `Assign`-target `Var`, `InstanceVar`, `ClassVar`,
    #    and `Call` node whose name (including any trailing `?`/`!`) matches the
    #    word under the cursor.
    # 3. For local variables, restrict results to the innermost enclosing
    #    `Def` body so that identically-named variables in different method
    #    scopes are never cross-highlighted.
    # 4. Fall back to a word-boundary text scan when the source cannot be
    #    parsed, so the feature always returns something useful.
    def self.document_highlights(
      source : String,
      position : ::LSP::Position,
    ) : Array(::LSP::DocumentHighlight)
      lines = source.split('\n')
      return [] of ::LSP::DocumentHighlight if position.line >= lines.size

      word = extract_word_with_suffix(lines[position.line], position.character)
      return [] of ::LSP::DocumentHighlight unless word

      # Attempt PSI-based highlighting.
      begin
        parser = ::Crystal::Parser.new(source)
        ast    = parser.parse

        # Determine the innermost Def (if any) containing the cursor so we can
        # scope local-variable highlights.
        scope_def = find_enclosing_def(ast, position.line, position.character)

        collector = HighlightCollector.new(word, scope_def, lines)
        ast.accept(collector)

        return collector.results unless collector.results.empty?
      rescue
        # Fall through to text scan.
      end

      # Fallback: word-boundary scan (does not distinguish scopes).
      text_scan_highlights(lines, word)
    end

    # Collect every AST node whose name matches *target* within the allowed
    # scope subtree.
    private class HighlightCollector < ::Crystal::Visitor
      getter results = [] of ::LSP::DocumentHighlight

      def initialize(
        @target : String,
        @scope_def : ::Crystal::Def?,
        @lines : Array(String),
      )
        # When we are inside a scope_def, we skip nodes that lie outside it.
        @inside_scope = @scope_def.nil?
        @scope_entered = false
      end

      def visit(node : ::Crystal::Def) : Bool
        if sd = @scope_def
          if same_node?(node, sd)
            @inside_scope  = true
            @scope_entered = true
            return true
          end
          # If we are looking for a scope-local var and this is a *different*
          # def, do not recurse into it so identically-named vars inside are
          # not highlighted.
          return false if @scope_entered
        end
        true
      end

      def end_visit(node : ::Crystal::Def)
        if sd = @scope_def
          @inside_scope = false if same_node?(node, sd)
        end
      end

      def visit(node : ::Crystal::Var) : Bool
        return true unless @inside_scope
        check_name(node.name, node, write: false)
        true
      end

      def visit(node : ::Crystal::Assign) : Bool
        return true unless @inside_scope
        if target = node.target.as?(::Crystal::Var)
          check_name(target.name, target, write: true)
        end
        true
      end

      def visit(node : ::Crystal::InstanceVar) : Bool
        check_name(node.name, node, write: false)
        true
      end

      def visit(node : ::Crystal::ClassVar) : Bool
        check_name(node.name, node, write: false)
        true
      end

      def visit(node : ::Crystal::Call) : Bool
        check_name(node.name, node, write: false)
        true
      end

      def visit(node : ::Crystal::Nop) : Bool
        false
      end

      def visit(node : ::Crystal::ASTNode) : Bool
        true
      end

      private def check_name(
        name : String,
        node : ::Crystal::ASTNode,
        write : Bool,
      )
        return unless name == @target

        loc = node.location
        return unless loc

        line_num = loc.line_number - 1
        col      = loc.column_number - 1
        return unless line_num >= 0 && line_num < @lines.size

        range = ::LSP::Range.new(
          ::LSP::Position.new(line_num, col),
          ::LSP::Position.new(line_num, col + name.size),
        )
        kind = write ? ::LSP::DocumentHighlightKind::Write : ::LSP::DocumentHighlightKind::Read
        @results << ::LSP::DocumentHighlight.new(range, kind)
      end

      private def same_node?(a : ::Crystal::ASTNode, b : ::Crystal::ASTNode) : Bool
        return false unless a.location == b.location
        return false unless a.end_location == b.end_location
        true
      end
    end

    # Walk the AST and return the innermost `Def` node whose source span
    # contains (line, char) (0-based).
    private def self.find_enclosing_def(
      root : ::Crystal::ASTNode,
      line : Int32,
      char : Int32,
    ) : ::Crystal::Def?
      best : ::Crystal::Def? = nil

      visitor = DefScopeVisitor.new(line, char) do |d|
        best = d
      end
      begin
        root.accept(visitor)
      rescue
      end
      best
    end

    private class DefScopeVisitor < ::Crystal::Visitor
      def initialize(@line : Int32, @char : Int32, &@cb : ::Crystal::Def ->)
      end

      def visit(node : ::Crystal::Def) : Bool
        loc     = node.location
        end_loc = node.end_location
        return false unless loc && end_loc

        start_l = loc.line_number - 1
        end_l   = end_loc.line_number - 1

        if start_l <= @line && @line <= end_l
          @cb.call(node)
          return true
        end
        false
      end

      def visit(node : ::Crystal::ASTNode) : Bool
        loc     = node.location
        end_loc = node.end_location
        return true unless loc && end_loc
        start_l = loc.line_number - 1
        end_l   = end_loc.line_number - 1
        start_l <= @line && @line <= end_l
      end

      def visit(node : ::Crystal::Nop) : Bool
        false
      end
    end

    # Fallback plain text scan — no scope awareness.
    private def self.text_scan_highlights(
      lines : Array(String),
      word : String,
    ) : Array(::LSP::DocumentHighlight)
      result = [] of ::LSP::DocumentHighlight
      lines.each_with_index do |line, line_num|
        offset = 0
        while idx = line.index(word, offset)
          before_ok = idx == 0 || !word_char_or_suffix?(line[idx - 1])
          char_after = line[idx + word.size]?
          after_ok  = char_after.nil? || !word_char_or_suffix?(char_after)

          if before_ok && after_ok
            range = ::LSP::Range.new(
              ::LSP::Position.new(line_num, idx),
              ::LSP::Position.new(line_num, idx + word.size),
            )
            after_word = line[(idx + word.size)..]?.to_s.lstrip
            kind = after_word.starts_with?('=') && !after_word.starts_with?("==") ?
              ::LSP::DocumentHighlightKind::Write :
              ::LSP::DocumentHighlightKind::Read
            result << ::LSP::DocumentHighlight.new(range, kind)
          end
          offset = idx + 1
        end
      end
      result
    end

    # -------------------------------------------------------------------------
    # Folding Ranges  (PSI/AST-based)
    # -------------------------------------------------------------------------

    # Return folding ranges for *source* by walking the Crystal AST and
    # collecting the source span of every block-bearing node.
    #
    # This approach is immune to:
    # - Heredocs (the lexer collapses them; the AST node has correct locations)
    # - One-liner `if` modifiers (`x if cond` — the AST parses them as `If`
    #   but their start/end locations are on the same line, so they are skipped)
    # - Multiline strings (same reasoning as heredocs)
    #
    # Falls back to the previous indent-stack scan only when the source cannot
    # be parsed.
    def self.folding_ranges(source : String) : Array(::LSP::FoldingRange)
      begin
        parser = ::Crystal::Parser.new(source)
        ast    = parser.parse
        collector = FoldCollector.new
        ast.accept(collector)
        return collector.results unless collector.results.empty?
      rescue
        # Fall through to the indent-based fallback.
      end

      folding_ranges_text_fallback(source)
    end

    # Walk the AST and emit a FoldingRange for every block-bearing node whose
    # start line and end line differ.
    private class FoldCollector < ::Crystal::Visitor
      getter results = [] of ::LSP::FoldingRange

      def visit(node : ::Crystal::ASTNode) : Bool
        emit(node, kind: nil)
        true
      end

      def visit(node : ::Crystal::Nop) : Bool
        false
      end

      private def emit(node : ::Crystal::ASTNode, kind : String?)
        loc     = node.location
        end_loc = node.end_location
        return unless loc && end_loc

        start_l = loc.line_number - 1
        end_l   = end_loc.line_number - 1

        # Only fold multi-line constructs.
        return unless end_l > start_l

        # Only emit for constructs that carry a body.
        case node
        when ::Crystal::ClassDef, ::Crystal::ModuleDef,
             ::Crystal::EnumDef, ::Crystal::LibDef, ::Crystal::Def,
             ::Crystal::Macro, ::Crystal::FunDef,
             ::Crystal::If, ::Crystal::Unless, ::Crystal::Case,
             ::Crystal::While, ::Crystal::Until,
             ::Crystal::ExceptionHandler
          results << ::LSP::FoldingRange.new(
            start_line: start_l,
            end_line: end_l - 1,  # fold up to (not including) the closing `end`
            kind: kind,
          )
        when ::Crystal::Block
          results << ::LSP::FoldingRange.new(
            start_line: start_l,
            end_line: end_l,
            kind: kind,
          )
        end
      end
    end

    # Indent-stack fallback used only when the source cannot be parsed.
    BLOCK_OPENERS = /^\s*(?:class|module|struct|enum|lib|def|macro|if|elsif|else|unless|case|when|while|until|begin|do|annotation)\b/
    BLOCK_CLOSERS = /^\s*end\b/

    private def self.folding_ranges_text_fallback(source : String) : Array(::LSP::FoldingRange)
      lines  = source.split('\n')
      result = [] of ::LSP::FoldingRange
      stack  = [] of {Int32, Int32, String}

      lines.each_with_index do |line, i|
        indent   = line[/^\s*/].size
        stripped = line.strip

        next if stripped.starts_with?('#')

        if stripped.match(BLOCK_OPENERS)
          stack << {i, indent, ""}
          next
        end

        if stripped.match(BLOCK_CLOSERS)
          idx = stack.rindex { |_, s_indent, _| s_indent <= indent }
          if idx
            start_line, _, kind = stack.delete_at(idx)
            result << ::LSP::FoldingRange.new(
              start_line: start_line,
              end_line: i - 1,
              kind: kind,
            ) if i > start_line
          end
        end
      end

      stack.each do |start_line, _, kind|
        last = lines.size - 1
        result << ::LSP::FoldingRange.new(start_line: start_line, end_line: last, kind: kind) if last > start_line
      end

      result
    end

    # -------------------------------------------------------------------------
    # Selection Range  (PSI ancestor-chain walk)
    # -------------------------------------------------------------------------

    # For each *position* return a nested `SelectionRange` chain that expands
    # from the token under the cursor outward through every enclosing PSI node.
    #
    # Implementation:
    # 1. Parse the source.
    # 2. Collect all AST nodes whose location spans the cursor position, sorted
    #    from innermost to outermost.
    # 3. Build a linked `SelectionRange` chain from innermost to outermost,
    #    ending with the full-file range.
    # 4. Prepend a word-token range as the innermost level.
    #
    # Falls back to word → line when the source cannot be parsed.
    def self.selection_ranges(
      source : String,
      positions : Array(::LSP::Position),
    ) : Array(::LSP::SelectionRange)
      lines = source.split('\n')

      # Try PSI-based expansion.
      begin
        parser = ::Crystal::Parser.new(source)
        ast    = parser.parse

        return positions.map { |pos| psi_selection_range(ast, source, lines, pos) }
      rescue
        # Fall through.
      end

      # Fallback: word → line.
      positions.map { |pos| fallback_selection_range(lines, pos) }
    end

    private def self.psi_selection_range(
      ast : ::Crystal::ASTNode,
      source : String,
      lines : Array(String),
      pos : ::LSP::Position,
    ) : ::LSP::SelectionRange
      line_num = pos.line.clamp(0, lines.size - 1)
      line     = lines[line_num]

      # Collect candidate nodes that span (line, char).
      # The visitor visits parents before children, so candidates[0] is the
      # outermost (largest) spanning node and candidates[-1] is the innermost.
      candidates = [] of ::Crystal::ASTNode

      visitor = PositionSpanVisitor.new(line_num, pos.character) do |n|
        candidates << n
      end
      begin
        ast.accept(visitor)
      rescue
      end

      # Build the parent chain from the outside in:
      #   file_range (no parent)
      #     → outermost_node
      #       → next_inward_node
      #         → ...
      #           → innermost_node
      #             → word_range   (what the caller sees first)
      #
      # Because SelectionRange#parent points *outward*, we start with
      # file_range (parent = nil), then each inward node wraps the previous
      # chain as its parent.  We iterate candidates in order (outermost first)
      # so that each iteration produces a node whose parent is the already-built
      # outward chain.

      file_range = ::LSP::Range.new(
        ::LSP::Position.new(0, 0),
        ::LSP::Position.new(lines.size - 1, (lines.last?.try(&.size) || 0)),
      )
      chain = ::LSP::SelectionRange.new(file_range, nil)

      # Accumulate unique ranges to avoid adjacent duplicate nodes (e.g. an
      # expression node and its wrapper both having the same location).
      last_r : ::LSP::Range? = file_range
      candidates.each do |n|
        r = node_range(n)
        next unless r
        next if r == last_r
        chain  = ::LSP::SelectionRange.new(r, chain)
        last_r = r
      end

      # Innermost level: the word token under the cursor.
      word_start = pos.character
      word_end   = pos.character
      while word_start > 0 && word_char?(line[word_start - 1])
        word_start -= 1
      end
      while word_end < line.size && word_char_or_suffix?(line[word_end])
        word_end += 1
      end

      if word_start < word_end
        word_range = ::LSP::Range.new(
          ::LSP::Position.new(line_num, word_start),
          ::LSP::Position.new(line_num, word_end),
        )
        # Only prepend if the word range is strictly smaller than the innermost
        # AST node already in the chain.
        unless word_range == last_r
          return ::LSP::SelectionRange.new(word_range, chain)
        end
      end

      chain
    end

    private def self.node_range(node : ::Crystal::ASTNode) : ::LSP::Range?
      loc     = node.location
      end_loc = node.end_location || loc
      return unless loc && end_loc

      ::LSP::Range.new(
        ::LSP::Position.new(loc.line_number - 1, loc.column_number - 1),
        ::LSP::Position.new(end_loc.line_number - 1, end_loc.column_number - 1),
      )
    end

    # Collect nodes spanning (line, char), emitting them outermost-first.
    private class PositionSpanVisitor < ::Crystal::Visitor
      def initialize(@line : Int32, @char : Int32, &@cb : ::Crystal::ASTNode ->)
      end

      def visit(node : ::Crystal::ASTNode) : Bool
        loc     = node.location
        end_loc = node.end_location || loc
        return true unless loc && end_loc

        start_l = loc.line_number - 1
        end_l   = end_loc.line_number - 1

        unless start_l <= @line && @line <= end_l
          return start_l <= @line
        end

        # Check column bounds on a single-line node.
        if start_l == end_l
          start_c = loc.column_number - 1
          end_c   = end_loc.column_number - 1
          return false unless start_c <= @char && @char <= end_c
        end

        @cb.call(node)
        true
      end

      def visit(node : ::Crystal::Nop) : Bool
        false
      end
    end

    # Word → line fallback.
    private def self.fallback_selection_range(
      lines : Array(String),
      pos : ::LSP::Position,
    ) : ::LSP::SelectionRange
      line_num = pos.line.clamp(0, lines.size - 1)
      line     = lines[line_num]

      word_start = pos.character
      word_end   = pos.character
      while word_start > 0 && word_char?(line[word_start - 1])
        word_start -= 1
      end
      while word_end < line.size && word_char_or_suffix?(line[word_end])
        word_end += 1
      end

      word_range = ::LSP::Range.new(
        ::LSP::Position.new(line_num, word_start),
        ::LSP::Position.new(line_num, word_end),
      )
      line_range = ::LSP::Range.new(
        ::LSP::Position.new(line_num, 0),
        ::LSP::Position.new(line_num, line.size),
      )
      outer = ::LSP::SelectionRange.new(line_range, nil)
      ::LSP::SelectionRange.new(word_range, outer)
    end

    # -------------------------------------------------------------------------
    # Linked Editing Range
    # -------------------------------------------------------------------------

    # Crystal does not use closing-name syntax (`end ClassName`), so linked
    # editing can only link the *name token* in a `class`/`module`/`struct`
    # declaration.  We return the single name range; editors that support
    # linked editing will propagate renames to the selection.
    #
    # If the cursor is not on a declaration name, returns nil.
    def self.linked_editing_ranges(
      source : String,
      position : ::LSP::Position,
    ) : ::LSP::LinkedEditingRanges?
      lines = source.split('\n')
      return if position.line >= lines.size

      line = lines[position.line]

      if m = line.match(/^\s*(?:class|module|struct|enum|lib)\s+(\w[\w:]*)/)
        name = m[1]
        name_start = (m.begin(1) || 0)
        # Only link if the cursor is actually on the name token.
        if position.character >= name_start && position.character <= name_start + name.size
          range = ::LSP::Range.new(
            ::LSP::Position.new(position.line, name_start),
            ::LSP::Position.new(position.line, name_start + name.size),
          )
          return ::LSP::LinkedEditingRanges.new([range])
        end
      end

      nil
    end

    # -------------------------------------------------------------------------
    # Document Links
    # -------------------------------------------------------------------------

    # Scan *source* for `require "..."` statements and resolve each path to a
    # file URI using the same logic as `SemanticAnalyzer#resolve_require_path`.
    # *workspace_root* is the file-system path (not URI) of the project root.
    def self.document_links(
      uri : String,
      source : String,
      workspace_root : String?,
    ) : Array(::LSP::DocumentLink)
      lines = source.split('\n')
      result = [] of ::LSP::DocumentLink
      current_file = uri_to_path(uri)

      lines.each_with_index do |line, line_num|
        next unless m = line.match(/require\s+["']([^"']+)["']/)
        require_path = m[1]
        quote_start  = (m.begin(1) || 0)
        quote_end    = (m.end(1) || 0)

        target = resolve_require(require_path, current_file, workspace_root)
        next unless target

        range = ::LSP::Range.new(
          ::LSP::Position.new(line_num, quote_start - 1), # include the opening quote
          ::LSP::Position.new(line_num, quote_end + 1),   # include the closing quote
        )
        result << ::LSP::DocumentLink.new(range: range, target: target)
      end

      result
    end

    # -------------------------------------------------------------------------
    # Call Hierarchy
    # -------------------------------------------------------------------------

    # Prepare a call hierarchy item for the method at *position*.
    # Returns nil if the cursor is not on a method name.
    def self.prepare_call_hierarchy(
      uri : String,
      source : String,
      position : ::LSP::Position,
      index : Index::WorkspaceIndex,
    ) : Array(::LSP::CallHierarchyItem)?
      lines = source.split('\n')
      return if position.line >= lines.size

      # Use extract_word_with_suffix (not extract_word) so a cursor on
      # `empty?` or `save!` resolves the whole identifier including its `?`/`!`
      # — the indexer stores method names with that suffix intact.
      word = extract_word_with_suffix(lines[position.line], position.character)
      return unless word

      # Methods are indexed under their fully-qualified name ("MyClass::run"),
      # so match on exact name or a "::name" suffix rather than exact-only,
      # or every namespaced method (i.e. almost all of them) would fail to
      # resolve here.
      entries = index.find_by_name_or_suffix(word).select { |e| e.kind == "method" }
      return if entries.empty?

      items = entries.map do |e|
        range = ::LSP::Range.new(
          ::LSP::Position.new(e.line, e.column),
          ::LSP::Position.new(e.line, e.column + word.size),
        )
        file_uri = path_to_uri(e.file)
        ::LSP::CallHierarchyItem.new(
          name: e.name.split("::").last,
          kind: ::LSP::SymbolKind::Method,
          detail: e.signature || "",
          uri: file_uri,
          range: range,
          selection_range: range,
        )
      end

      items
    end

    # Return incoming calls for *item* by scanning the index for callers.
    # A "caller" is any method whose source contains a call to item.name.
    def self.incoming_calls(
      item : ::LSP::CallHierarchyItem,
      index : Index::WorkspaceIndex,
      sources : Hash(String, String),
    ) : Array(::LSP::CallHierarchyIncomingCall)
      callee_name = item.name
      result = [] of ::LSP::CallHierarchyIncomingCall

      # Walk every indexed method and check if its source region references
      # the callee name.
      index.all_of_kind("method").each do |caller_entry|
        caller_file = caller_entry.file
        src = sources[path_to_uri(caller_file)]? || (File.exists?(caller_file) ? File.read(caller_file) : next)
        src_lines = src.split('\n')

        # Scope the scan to the caller's own method body (def_line..body_end),
        # mirroring outgoing_calls, instead of scanning the whole file — a
        # whole-file scan matches the callee name in every other method too.
        def_line = caller_entry.line
        next if def_line >= src_lines.size
        indent   = (src_lines[def_line]? || "")[/^\s*/].size
        body_end = def_line
        (def_line + 1...src_lines.size).each do |i|
          if src_lines[i].strip == "end" && src_lines[i][/^\s*/].size == indent
            body_end = i
            break
          end
        end

        from_ranges = [] of ::LSP::Range

        (def_line..body_end).each do |line_num|
          line = src_lines[line_num]? || next
          offset = 0
          while idx = line.index(callee_name, offset)
            before_ok = idx == 0 || !word_char?(line[idx - 1])
            after_ok  = (idx + callee_name.size) >= line.size || !word_char?(line[idx + callee_name.size])
            if before_ok && after_ok
              from_ranges << ::LSP::Range.new(
                ::LSP::Position.new(line_num, idx),
                ::LSP::Position.new(line_num, idx + callee_name.size),
              )
            end
            offset = idx + 1
          end
        end

        next if from_ranges.empty?

        caller_uri   = path_to_uri(caller_file)
        caller_range = ::LSP::Range.new(
          ::LSP::Position.new(caller_entry.line, caller_entry.column),
          ::LSP::Position.new(caller_entry.line, caller_entry.column + caller_entry.name.split("::").last.size),
        )
        caller_item = ::LSP::CallHierarchyItem.new(
          name: caller_entry.name.split("::").last,
          kind: ::LSP::SymbolKind::Method,
          detail: caller_entry.signature || "",
          uri: caller_uri,
          range: caller_range,
          selection_range: caller_range,
        )
        result << ::LSP::CallHierarchyIncomingCall.new(from: caller_item, from_ranges: from_ranges)
      end

      result
    end

    # Return outgoing calls for *item* by scanning the method's source body
    # for tokens that match indexed method names.
    def self.outgoing_calls(
      item : ::LSP::CallHierarchyItem,
      index : Index::WorkspaceIndex,
      sources : Hash(String, String),
    ) : Array(::LSP::CallHierarchyOutgoingCall)
      caller_uri = item.uri
      src = sources[caller_uri]? || begin
        path = uri_to_path(caller_uri)
        File.exists?(path) ? File.read(path) : return [] of ::LSP::CallHierarchyOutgoingCall
      end

      src_lines = src.split('\n')

      # Locate the method body lines (between def and matching end).
      def_line  = item.range.start.line
      indent    = (src_lines[def_line]? || "")[/^\s*/].size
      body_end  = def_line
      (def_line + 1...src_lines.size).each do |i|
        if src_lines[i].strip == "end" && src_lines[i][/^\s*/].size == indent
          body_end = i
          break
        end
      end

      result = [] of ::LSP::CallHierarchyOutgoingCall

      all_methods = index.all_of_kind("method")

      all_methods.each do |callee_entry|
        callee_name = callee_entry.name.split("::").last
        next if callee_name.size < 2  # skip very short names to reduce noise

        to_ranges = [] of ::LSP::Range
        (def_line..body_end).each do |line_num|
          line   = src_lines[line_num]? || next
          offset = 0
          while idx = line.index(callee_name, offset)
            before_ok = idx == 0 || !word_char?(line[idx - 1])
            after_ok  = (idx + callee_name.size) >= line.size || !word_char?(line[idx + callee_name.size])
            if before_ok && after_ok
              to_ranges << ::LSP::Range.new(
                ::LSP::Position.new(line_num, idx),
                ::LSP::Position.new(line_num, idx + callee_name.size),
              )
            end
            offset = idx + 1
          end
        end

        next if to_ranges.empty?

        callee_uri   = path_to_uri(callee_entry.file)
        callee_range = ::LSP::Range.new(
          ::LSP::Position.new(callee_entry.line, callee_entry.column),
          ::LSP::Position.new(callee_entry.line, callee_entry.column + callee_name.size),
        )
        callee_item = ::LSP::CallHierarchyItem.new(
          name: callee_name,
          kind: ::LSP::SymbolKind::Method,
          detail: callee_entry.signature || "",
          uri: callee_uri,
          range: callee_range,
          selection_range: callee_range,
        )
        result << ::LSP::CallHierarchyOutgoingCall.new(to: callee_item, from_ranges: to_ranges)
      end

      result
    end

    # -------------------------------------------------------------------------
    # Type Hierarchy
    # -------------------------------------------------------------------------

    # Prepare a type hierarchy item for the class/module at *position*.
    def self.prepare_type_hierarchy(
      uri : String,
      source : String,
      position : ::LSP::Position,
      index : Index::WorkspaceIndex,
    ) : Array(::LSP::TypeHierarchyItem)?
      lines = source.split('\n')
      return if position.line >= lines.size

      word = extract_word(lines[position.line], position.character)
      return unless word

      # Types are indexed under their fully-qualified name ("Outer::Inner"),
      # so match on exact name or a "::name" suffix rather than exact-only,
      # or nested classes/modules/structs would fail to resolve here.
      entries = index.find_by_name_or_suffix(word).select { |e| %w[class module struct].includes?(e.kind) }
      return if entries.empty?

      entries.map do |e|
        range = ::LSP::Range.new(
          ::LSP::Position.new(e.line, e.column),
          ::LSP::Position.new(e.line, e.column + word.size),
        )
        ::LSP::TypeHierarchyItem.new(
          name: e.name.split("::").last,
          kind: kind_for(e.kind),
          detail: e.type_name,
          uri: path_to_uri(e.file),
          range: range,
          selection_range: range,
        )
      end
    end

    # Return supertypes for *item*: the direct `< Parent` ancestors and every
    # `include Module` in the class body.
    def self.supertypes(
      item : ::LSP::TypeHierarchyItem,
      index : Index::WorkspaceIndex,
    ) : Array(::LSP::TypeHierarchyItem)
      entries = index.find_by_name_or_suffix(item.name).select { |e| %w[class module struct].includes?(e.kind) }
      result = [] of ::LSP::TypeHierarchyItem

      entries.each do |e|
        # e.type_name holds the `< Parent` value for classes.
        parent_name = e.type_name
        next if parent_name.empty? || parent_name == "Object" || parent_name == "Module" ||
                parent_name == "Struct" || parent_name == "Enum"

        parent_entries = index.find_by_name_or_suffix(parent_name).select { |p| %w[class module struct].includes?(p.kind) }
        parent_entries.each do |pe|
          r = ::LSP::Range.new(::LSP::Position.new(pe.line, pe.column), ::LSP::Position.new(pe.line, pe.column + parent_name.size))
          result << ::LSP::TypeHierarchyItem.new(
            name: parent_name.split("::").last,
            kind: kind_for(pe.kind),
            detail: pe.type_name,
            uri: path_to_uri(pe.file),
            range: r,
            selection_range: r,
          )
        end
      end

      result
    end

    # Return subtypes of *item*: classes/modules that inherit from or include it.
    def self.subtypes(
      item : ::LSP::TypeHierarchyItem,
      index : Index::WorkspaceIndex,
    ) : Array(::LSP::TypeHierarchyItem)
      result = [] of ::LSP::TypeHierarchyItem
      target = item.name

      index.all_of_kind("class").each do |e|
        next unless e.type_name == target || e.type_name.ends_with?("::#{target}")
        r = ::LSP::Range.new(::LSP::Position.new(e.line, e.column), ::LSP::Position.new(e.line, e.column + e.name.split("::").last.size))
        result << ::LSP::TypeHierarchyItem.new(
          name: e.name.split("::").last,
          kind: ::LSP::SymbolKind::Class,
          detail: e.type_name,
          uri: path_to_uri(e.file),
          range: r,
          selection_range: r,
        )
      end

      index.all_of_kind("struct").each do |e|
        next unless e.type_name == target || e.type_name.ends_with?("::#{target}")
        r = ::LSP::Range.new(::LSP::Position.new(e.line, e.column), ::LSP::Position.new(e.line, e.column + e.name.split("::").last.size))
        result << ::LSP::TypeHierarchyItem.new(
          name: e.name.split("::").last,
          kind: ::LSP::SymbolKind::Struct,
          detail: e.type_name,
          uri: path_to_uri(e.file),
          range: r,
          selection_range: r,
        )
      end

      result
    end

    # -------------------------------------------------------------------------
    # Shared helpers
    # -------------------------------------------------------------------------

    # Extract the identifier under *char* in *line*, including a trailing
    # `?` or `!` suffix (Crystal method conventions).
    private def self.extract_word_with_suffix(line : String, char : Int32) : String?
      return if char < 0 || char > line.size
      s = char
      while s > 0 && word_char?(line[s - 1])
        s -= 1
      end
      e = char
      while e < line.size && word_char?(line[e])
        e += 1
      end
      # Include a trailing `?` or `!` if present.
      if e < line.size && (line[e] == '?' || line[e] == '!')
        e += 1
      end
      return if s == e
      line[s...e]
    end

    private def self.extract_word(line : String, char : Int32) : String?
      return if char < 0 || char > line.size
      s = char
      while s > 0 && word_char?(line[s - 1])
        s -= 1
      end
      e = char
      while e < line.size && word_char?(line[e])
        e += 1
      end
      return if s == e
      w = line[s...e]
      w = w.rstrip("?!")
      w.empty? ? nil : w
    end

    private def self.word_char?(c : Char) : Bool
      c.alphanumeric? || c == '_'
    end

    # Returns true for characters that may appear inside or at the end of a
    # Crystal identifier (including `?` and `!` method suffixes).
    private def self.word_char_or_suffix?(c : Char) : Bool
      c.alphanumeric? || c == '_' || c == '?' || c == '!'
    end

    private def self.uri_to_path(uri : String) : String
      return uri unless uri.starts_with?("file://")
      p = uri.sub(/^file:\/\//, "")
      begin
        p = URI.decode(p)
      rescue
      end
      p = p[1..] if p =~ /^\/([a-zA-Z]):/
      p.gsub('/', File::SEPARATOR)
    end

    private def self.path_to_uri(path : String) : String
      p = path.gsub(File::SEPARATOR, '/')
      if p =~ /^([a-zA-Z]):/
        drive = p[0].to_s
        rest  = p[2..]
        p = "#{drive}%3A#{rest}"
      end
      p = p.lstrip('/')
      "file:///#{p}"
    end

    private def self.resolve_require(
      require_path : String,
      current_file : String,
      workspace_root : String?,
    ) : String?
      if require_path.starts_with?("./") || require_path.starts_with?("../")
        base = File.dirname(current_file)
        candidate = File.expand_path(require_path + ".cr", base)
        return path_to_uri(candidate) if File.exists?(candidate)
        return nil
      end

      if root = workspace_root
        shard_main = File.join(root, "lib", require_path, "src", "#{require_path}.cr")
        return path_to_uri(shard_main) if File.exists?(shard_main)

        shard_base = File.join(root, "lib", require_path)
        if Dir.exists?(shard_base)
          alts = [
            File.join(shard_base, "src", "#{File.basename(require_path)}.cr"),
            File.join(shard_base, "src", "lib.cr"),
            File.join(shard_base, "#{File.basename(require_path)}.cr"),
          ]
          alts.each { |a| return path_to_uri(a) if File.exists?(a) }
        end
      end

      nil
    end

    private def self.kind_for(kind : String) : ::LSP::SymbolKind
      case kind
      when "class"  then ::LSP::SymbolKind::Class
      when "module" then ::LSP::SymbolKind::Module
      when "struct" then ::LSP::SymbolKind::Struct
      else               ::LSP::SymbolKind::Class
      end
    end
  end
end

# ---------------------------------------------------------------------------
# Protocol structs for Phase 4 handlers
# ---------------------------------------------------------------------------

module LSP
  # Document highlight

  enum DocumentHighlightKind
    Text  = 1
    Read  = 2
    Write = 3

    def to_json(json : JSON::Builder)
      json.number(value)
    end
  end

  struct DocumentHighlight
    include JSON::Serializable

    property range : Range
    property kind : DocumentHighlightKind?

    def initialize(@range : Range, @kind : DocumentHighlightKind? = nil)
    end
  end

  # Folding range

  struct FoldingRange
    include JSON::Serializable

    @[JSON::Field(key: "startLine")]
    property start_line : Int32
    @[JSON::Field(key: "endLine")]
    property end_line : Int32
    property kind : String?

    def initialize(@start_line : Int32, @end_line : Int32, @kind : String? = nil)
    end
  end

  struct FoldingRangeParams
    include JSON::Serializable

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

  # Selection range

  class SelectionRange
    include JSON::Serializable

    property range : Range
    property? parent : SelectionRange?

    def initialize(@range : Range, @parent : SelectionRange? = nil)
    end
  end

  struct SelectionRangeParams
    include JSON::Serializable

    @[JSON::Field(key: "textDocument")]
    property text_document : TextDocumentIdentifier
    property positions : Array(Position)
  end

  # Linked editing range

  struct LinkedEditingRangeParams
    include JSON::Serializable

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

  struct LinkedEditingRanges
    include JSON::Serializable

    property ranges : Array(Range)
    @[JSON::Field(key: "wordPattern")]
    property? word_pattern : String?

    def initialize(@ranges : Array(Range), @word_pattern : String? = nil)
    end
  end

  # Document link

  struct DocumentLink
    include JSON::Serializable

    property range : Range
    property target : String?
    property? data : JSON::Any?

    def initialize(@range : Range, @target : String? = nil)
    end
  end

  struct DocumentLinkParams
    include JSON::Serializable

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

  # Call hierarchy

  struct CallHierarchyPrepareParams
    include JSON::Serializable

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

  struct CallHierarchyItem
    include JSON::Serializable

    property name : String
    property kind : SymbolKind
    property detail : String?
    property uri : DocumentUri
    property range : Range
    @[JSON::Field(key: "selectionRange")]
    property selection_range : Range
    property? data : JSON::Any?

    def initialize(
      @name : String,
      @kind : SymbolKind,
      @detail : String?,
      @uri : DocumentUri,
      @range : Range,
      @selection_range : Range,
    )
    end
  end

  struct CallHierarchyIncomingCallsParams
    include JSON::Serializable

    property item : CallHierarchyItem
  end

  struct CallHierarchyOutgoingCallsParams
    include JSON::Serializable

    property item : CallHierarchyItem
  end

  struct CallHierarchyIncomingCall
    include JSON::Serializable

    property from : CallHierarchyItem
    @[JSON::Field(key: "fromRanges")]
    property from_ranges : Array(Range)

    def initialize(@from : CallHierarchyItem, @from_ranges : Array(Range))
    end
  end

  struct CallHierarchyOutgoingCall
    include JSON::Serializable

    property to : CallHierarchyItem
    @[JSON::Field(key: "fromRanges")]
    property from_ranges : Array(Range)

    def initialize(@to : CallHierarchyItem, @from_ranges : Array(Range))
    end
  end

  # Type hierarchy

  struct TypeHierarchyPrepareParams
    include JSON::Serializable

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

  struct TypeHierarchyItem
    include JSON::Serializable

    property name : String
    property kind : SymbolKind
    property detail : String?
    property uri : DocumentUri
    property range : Range
    @[JSON::Field(key: "selectionRange")]
    property selection_range : Range
    property? data : JSON::Any?

    def initialize(
      @name : String,
      @kind : SymbolKind,
      @detail : String?,
      @uri : DocumentUri,
      @range : Range,
      @selection_range : Range,
    )
    end
  end

  struct TypeHierarchySupertypesParams
    include JSON::Serializable

    property item : TypeHierarchyItem
  end

  struct TypeHierarchySubtypesParams
    include JSON::Serializable

    property item : TypeHierarchyItem
  end
end
