require "./inspection"
require "compiler/crystal/syntax"

module Lattice
  module Inspections
    # Reports local variable declarations that shadow an outer-scope binding
    # with the same name.
    #
    # Crystal allows inner-scope variables to shadow outer ones without a
    # warning, but this is a common source of subtle bugs — particularly when
    # a developer intends to reassign the outer variable.
    #
    # Only plain local variables (Crystal::Var / Crystal::Assign targets) are
    # considered.  Parameters and instance variables are tracked by their own
    # inspections.
    class ShadowedVariableInspection < Inspection
      def run(
        uri : String,
        source : String,
        lines : Array(String),
        ast : ::Crystal::ASTNode,
      ) : Array(::LSP::Diagnostic)
        diagnostics = [] of ::LSP::Diagnostic
        visitor = Visitor.new(diagnostics)
        ast.accept(visitor)
        diagnostics
      end

      private class Visitor < ::Crystal::Visitor
        # Each entry is a mapping name → declaration line (0-based) for the
        # current scope level.
        def initialize(@diagnostics : Array(::LSP::Diagnostic))
          @scope_stack = [] of Hash(String, Int32)
        end

        # Push a new scope for any block-like construct that introduces a scope
        # boundary in Crystal: Def, Block, While, Until, If branches, etc.
        def visit(node : ::Crystal::Def) : Bool
          push_scope(node.args.map(&.name))
          true
        end

        def end_visit(node : ::Crystal::Def) : Nil
          pop_scope
        end

        def visit(node : ::Crystal::Block) : Bool
          push_scope(node.args.map(&.name))
          true
        end

        def end_visit(node : ::Crystal::Block) : Nil
          pop_scope
        end

        def visit(node : ::Crystal::Assign) : Bool
          if node.target.is_a?(::Crystal::Var)
            var = node.target.as(::Crystal::Var)
            check_and_record(var.name, node.location)
          end
          true
        end

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

        private def push_scope(param_names : Array(String) = [] of String)
          scope = Hash(String, Int32).new
          param_names.each { |n| scope[n] = -1 } # -1 = parameter, never flagged
          @scope_stack.push(scope)
        end

        private def pop_scope
          @scope_stack.pop?
        end

        private def check_and_record(name : String, loc : ::Crystal::Location?)
          return if name.starts_with?('_')
          return if @scope_stack.empty?

          # Check whether any outer scope already defines this name.
          outer_line = nil
          @scope_stack[0...-1].each do |scope|
            if existing = scope[name]?
              outer_line = existing unless existing == -1
              break
            end
          end

          current_scope = @scope_stack.last
          current_scope[name] = loc ? loc.line_number - 1 : 0

          if outer_line && loc
            line = (loc.line_number - 1).clamp(0, Int32::MAX)
            col  = (loc.column_number - 1).clamp(0, Int32::MAX)
            @diagnostics << ::LSP::Diagnostic.new(
              ::LSP::Range.new(
                ::LSP::Position.new(line, col),
                ::LSP::Position.new(line, col + name.size),
              ),
              "Variable '#{name}' shadows outer declaration on line #{outer_line + 1}",
              ::LSP::DiagnosticSeverity::Warning,
              "lattice",
            )
          end
        end
      end
    end
  end
end
