// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Konstantin Vyatkin <tino@vtkn.io>

//! Ruby Prism AST walker that produces a populated `MetricSpace`.
//!
//! Recursion is driven by ruby-prism's
//! [`Visit`](https://docs.rs/ruby-prism/latest/ruby_prism/trait.Visit.html)
//! trait — the per-node-type visitor generated by Prism's `config.yml`.
//! Same architecture as `mehen-php`'s `mago_syntax::walker::Walker` and
//! `mehen-python`'s `ruff_python_ast::visitor::source_order::SourceOrderVisitor`:
//! we override the metric-bearing hooks, call the matching free function
//! `visit_<name>_node(self, node)` to keep walking children, and use
//! `visit_branch_node_enter`/`_leave` only when we genuinely need
//! cross-node bookkeeping. Default behaviour for every other node is
//! "descend into children" — which is exactly what we want for the
//! structural-only kinds.
//!
//! The walker follows the same per-space `State` accumulator pattern
//! used by the other language crates (`mehen-metrics::state`): one
//! `State` per opened space, finalize on close, fold child stats into
//! parent.
//!
//! ## Ruby-specific design decisions
//!
//! Documented in `docs/ruby-prism-spec.md`. The short version:
//!
//! - **Modifier forms** (`x if y`, `x unless y`, `x while y`,
//!   `x until y`): Prism collapses block and modifier forms into one
//!   node struct; the absence of `end_keyword_loc` / `closing_loc`
//!   distinguishes the modifier form. Per Sonar's cognitive-complexity
//!   spec we still bump `+1` for modifier forms but DO NOT increase
//!   nesting (the modifier sits on the trailing edge of the statement,
//!   it is not a nesting structure). Cyclomatic counts modifier and
//!   block forms identically — both are decision points.
//!
//! - **`rescue` modifier (`expr rescue fallback`)**: a separate node
//!   kind in Prism (`RescueModifierNode`), distinct from the
//!   block-form `RescueNode` inside a `BeginNode`. We treat the
//!   modifier as `+1` cognitive without nesting, the block form as
//!   nesting-bumping (matches legacy walker per-Sonar policy).
//!
//! - **`case … in` pattern matching**: Prism exposes `CaseMatchNode`
//!   with `InNode` clauses (separate from classic `CaseNode` /
//!   `WhenNode`). Each `in` clause is a decision point; the `case`
//!   itself bumps cognitive nesting once.
//!
//! - **Halstead from AST, not tokens**: ruby-prism does NOT expose a
//!   public token stream (verified — `pm_token_t` / `pm_lex_*` symbols
//!   are not allowlisted by `ruby-prism-sys`'s bindgen). We derive
//!   operator/operand counts from the AST: `CallNode::name()` for
//!   arithmetic / comparison operators (Ruby parses `a + b` as a
//!   call to `:+` on `a` with argument `b`), the `*WriteNode` /
//!   `*OperatorWriteNode` / `*AndWriteNode` / `*OrWriteNode` families
//!   for assignment operators, dedicated visit hooks for keyword
//!   operators (`def`, `class`, `if`, …), and leaf literal / variable
//!   nodes for operands. The legacy tree-sitter walker also counted
//!   from the CST (the grammar does not expose a flat token stream
//!   either), so this is parity-preserving — see
//!   `docs/ruby-prism-spec.md §3.1` for the per-token-class table.
//!
//! - **Block under lambda**: a `BlockNode` whose immediate parent is a
//!   `LambdaNode` is the lambda body, not an additional nested
//!   closure. We track this via the `inside_lambda_body` flag so the
//!   block-call visit does not emit a duplicate `nom.record_closure()`
//!   nor double the cognitive `lambda` counter.

use mehen_core::{LineIndex, MetricSpace, SourceSpan, SpaceKind};
use mehen_metrics::{
    ContainerKind, HalsteadOperand, HalsteadOperator, MetricTreeBuilder, State, apply_state_to,
    close_space, finalize_state,
};
use ruby_prism::{
    AndNode, BeginNode, BlockNode, BreakNode, CallNode, CallOperatorWriteNode, CaseMatchNode,
    CaseNode, ClassNode, ClassVariableReadNode, ConstantReadNode, ConstantWriteNode, DefNode,
    ElseNode, FalseNode, FloatNode, ForNode, GlobalVariableReadNode, IfNode, InNode,
    IndexOperatorWriteNode, InstanceVariableReadNode, InstanceVariableWriteNode, IntegerNode,
    LambdaNode, LocalVariableAndWriteNode, LocalVariableOperatorWriteNode,
    LocalVariableOrWriteNode, LocalVariableReadNode, LocalVariableWriteNode, MatchPredicateNode,
    ModuleNode, NextNode, NilNode, Node, OptionalParameterNode, OrNode, ParseResult, RedoNode,
    RequiredParameterNode, RescueModifierNode, RescueNode, ReturnNode, SelfNode,
    SingletonClassNode, StringNode, SymbolNode, TrueNode, UnlessNode, UntilNode, Visit, WhenNode,
    WhileNode, YieldNode, visit_and_node, visit_begin_node, visit_block_node, visit_break_node,
    visit_call_node, visit_call_operator_write_node, visit_case_match_node, visit_case_node,
    visit_class_node, visit_constant_write_node, visit_def_node, visit_else_node, visit_for_node,
    visit_if_node, visit_in_node, visit_index_operator_write_node,
    visit_instance_variable_write_node, visit_lambda_node, visit_local_variable_and_write_node,
    visit_local_variable_operator_write_node, visit_local_variable_or_write_node,
    visit_local_variable_write_node, visit_match_predicate_node, visit_module_node,
    visit_next_node, visit_optional_parameter_node, visit_or_node, visit_redo_node,
    visit_rescue_modifier_node, visit_rescue_node, visit_return_node, visit_singleton_class_node,
    visit_unless_node, visit_until_node, visit_when_node, visit_while_node, visit_yield_node,
};
use smol_str::SmolStr;

/// Drive the walker over a parsed Ruby program. Crate-internal entry
/// point — only `mehen_ruby::RubyAnalyzer::analyze` calls this; not
/// part of any cross-crate API.
pub(crate) fn walk_program(
    parse: &ParseResult<'_>,
    source: &str,
    line_index: &LineIndex,
) -> MetricSpace {
    let unit_span = SourceSpan {
        start_byte: 0,
        end_byte: u32::try_from(source.len()).unwrap_or(u32::MAX),
        start_line: 1,
        end_line: line_index.line_count(),
    };

    let mut visitor = Visitor::new(line_index, unit_span);
    let root = parse.node();
    visitor.visit(&root);

    // LOC: Ruby comments come from `parse.comments()` (inline `#`
    // lines and `=begin`/`=end` block-doc comments). The legacy
    // tree-sitter Loc rule counts both as `cloc` lines, but on the
    // unit only — that loses per-method `loc.cloc` for any comment
    // inside a method body. Route each comment by byte range to the
    // deepest enclosing scope; `finish` then propagates them up the
    // parent chain.
    for comment in parse.comments() {
        let loc = comment.location();
        let start = u32::try_from(loc.start_offset()).unwrap_or(0);
        let end = u32::try_from(loc.end_offset()).unwrap_or(0);
        let start_row = line_index.line_at(start).saturating_sub(1);
        let end_row = line_index.line_at(end.saturating_sub(1)).saturating_sub(1);
        visitor.halstead_routing.observe_comment(
            start,
            end,
            &mut visitor.stack[0].loc,
            start_row,
            end_row,
        );
    }

    visitor.finish()
}

struct Visitor<'a> {
    line_index: &'a LineIndex,
    tree: MetricTreeBuilder,
    /// Per-space accumulator stack — index 0 is the unit.
    stack: Vec<State>,
    /// Parallel to `stack`: the SpaceKind of each frame so we can tell
    /// "what's the enclosing class-like" without re-walking.
    kinds: Vec<SpaceKind>,
    /// Inherited cognitive context. Mirrors the legacy
    /// `(nesting, depth, lambda)` triple from
    /// `mehen-engine/src/legacy/metrics/cognitive.rs::RubyCode`.
    cognitive: CognitiveContext,
    /// True while walking the body of a lambda — used to suppress the
    /// per-block lambda bump for a block that IS the lambda body
    /// (matches the legacy `Block | DoBlock if parent != Lambda` rule).
    inside_lambda_body: bool,
    /// Depth of nested boolean (And/Or) expressions; used to detect the
    /// outermost boolean for the boolean-sequence collapser.
    bool_depth: u32,
    /// Total number of statement-shaped LLOC contributions so far on the
    /// current space. Ruby's prism does not surface a
    /// "statement-boundary" event in a clean way, so we observe lloc at
    /// statement-shaped node hooks (see `record_lloc_for_node`).
    _phantom: std::marker::PhantomData<&'a ()>,
    /// Routes comment / PLOC observations to the deepest enclosing
    /// space. Ruby's `parse.comments()` lives on the `ParseResult`
    /// (no AST wrapper to record on), so the walker observes them
    /// after the AST walk has populated the tracker entries —
    /// otherwise every comment lands on the unit and per-space
    /// `loc.cloc` is zero (PR #95 discussion_r3265962147).
    halstead_routing: mehen_metrics::SpaceRangeTracker,
}

#[derive(Clone, Copy, Debug, Default)]
struct CognitiveContext {
    nesting: u32,
    depth: u32,
    lambda: u32,
}

impl<'a> Visitor<'a> {
    fn new(line_index: &'a LineIndex, unit_span: SourceSpan) -> Self {
        let mut state = State::new();
        state.loc.set_span(
            unit_span.start_line.saturating_sub(1),
            unit_span.end_line.saturating_sub(1),
            true,
        );
        Self {
            line_index,
            tree: MetricTreeBuilder::new(unit_span),
            stack: vec![state],
            kinds: vec![SpaceKind::Unit],
            cognitive: CognitiveContext::default(),
            inside_lambda_body: false,
            bool_depth: 0,
            _phantom: std::marker::PhantomData,
            halstead_routing: mehen_metrics::SpaceRangeTracker::new(),
        }
    }

    fn current(&mut self) -> &mut State {
        self.stack.last_mut().expect("walker stack empty")
    }

    fn finish(mut self) -> MetricSpace {
        let mut unit_state = self.stack.pop().expect("walker stack underflow");
        finalize_state(&mut unit_state);
        // Route post-AST observations (only LOC for Ruby — Halstead is
        // emitted *during* the AST walk via `current()`) to nested
        // spaces. The tracker has accumulated comments routed by
        // byte range; `finalize_into_tree` propagates each entry's
        // contributions up the parent chain (set-union for ploc lines,
        // sum for comment counts) and overlays the LOC keys + MI on
        // the matching `MetricSpace`.
        let mut unit_halstead = std::mem::take(&mut unit_state.halstead);
        let mut unit_loc = std::mem::take(&mut unit_state.loc);
        let mut tree = self.tree.finish();
        self.halstead_routing
            .finalize_into_tree(&mut tree, &mut unit_halstead, &mut unit_loc);
        unit_state.halstead = unit_halstead;
        unit_state.loc = unit_loc;
        apply_state_to(unit_state, &mut tree.metrics);
        tree
    }

    fn open_space(
        &mut self,
        kind: SpaceKind,
        start_byte: u32,
        end_byte: u32,
        name: Option<String>,
    ) {
        let mut child = State::for_opened_space(kind.clone());
        let start_row = self.line_index.line_at(start_byte).saturating_sub(1);
        let end_row = self
            .line_index
            .line_at(end_byte.saturating_sub(1))
            .saturating_sub(1);
        child.loc.set_span(start_row, end_row, false);

        let span = SourceSpan {
            start_byte,
            end_byte,
            start_line: self.line_index.line_at(start_byte),
            end_line: self.line_index.line_at(end_byte.saturating_sub(1)),
        };
        let space_id = self.tree.open(kind.clone(), span, name);
        self.halstead_routing
            .record_open(space_id, start_byte, end_byte);
        self.stack.push(child);
        self.kinds.push(kind);
    }

    fn close_space(&mut self) {
        close_space(
            &mut self.stack,
            &mut self.kinds,
            &mut self.tree,
            &mut self.halstead_routing,
        );
    }

    /// Record an "actionable" statement at this node's start line.
    /// Mirrors how `loc.rs::PythonCode::compute` and the Phase-8 PHP
    /// walker decide what counts as an LLOC. Container nodes (def,
    /// class, module) are NOT LLOC — their bodies do.
    fn record_lloc(&mut self, byte_start: u32, is_lloc: bool) {
        let row = self.line_index.line_at(byte_start).saturating_sub(1);
        let cur = self.current();
        if is_lloc {
            cur.loc.observe_lloc();
        }
        cur.loc.observe_code_line(row);
    }

    /// Increase nesting + boolean-seq reset, mirroring legacy
    /// `increase_nesting` from `cognitive.rs:239`.
    fn cognitive_increase_nesting(&mut self) {
        let effective = self.cognitive.nesting + self.cognitive.depth + self.cognitive.lambda;
        self.current().cognitive.increase_nesting(effective);
        self.current().cognitive.boolean_seq.reset();
    }

    fn cognitive_increment_by_one(&mut self) {
        self.current().cognitive.increment_by_one();
        self.current().cognitive.boolean_seq.reset();
    }

    /// Observe a code line for a keyword location that prism exposes
    /// as a `Location` field on a parent AST node (e.g.
    /// `end_keyword_loc`, `closing_loc`). These tokens have no
    /// separate node visit in prism, but the legacy tree-sitter walker
    /// did visit them as anonymous-keyword tokens, contributing their
    /// start_row to PLOC. Per the PLOC definition (physical lines
    /// containing executable code), an `end` keyword line IS code, so
    /// we record it here.
    fn observe_keyword_line(&mut self, loc: &ruby_prism::Location<'_>) {
        let row = self
            .line_index
            .line_at(u32::try_from(loc.start_offset()).unwrap_or(0))
            .saturating_sub(1);
        self.current().loc.observe_code_line(row);
    }

    fn observe_optional_keyword_line(&mut self, loc: Option<ruby_prism::Location<'_>>) {
        if let Some(l) = loc {
            self.observe_keyword_line(&l);
        }
    }

    /// Halstead routes to the *current* (innermost) space so nested
    /// def/class bodies carry their own counts; the close path's
    /// `merge_child_into_parent` rolls these up into the enclosing
    /// scope and the unit (set-union for `n1`/`n2`, sum for
    /// `N1`/`N2`).
    fn record_halstead_op(&mut self, kind: &'static str) {
        self.current().halstead.observe_operator(HalsteadOperator {
            kind: SmolStr::new(kind),
            text: None,
        });
    }

    fn record_halstead_op_text(&mut self, kind: &'static str, text: &str) {
        self.current().halstead.observe_operator(HalsteadOperator {
            kind: SmolStr::new(kind),
            text: Some(SmolStr::new(text)),
        });
    }

    fn record_halstead_operand_text(&mut self, kind: &'static str, text: &str) {
        self.current().halstead.observe_operand(HalsteadOperand {
            kind: SmolStr::new(kind),
            text: Some(SmolStr::new(text)),
        });
    }
}

impl<'pr> Visit<'pr> for Visitor<'_> {
    // ─── Definitions / scopes ──────────────────────────────────────────────

    fn visit_def_node(&mut self, node: &DefNode<'pr>) {
        // `def name(params); body; end` — function-like space.
        let loc = node.location();
        let start = u32::try_from(loc.start_offset()).unwrap_or(0);
        let end = u32::try_from(loc.end_offset()).unwrap_or(0);
        let name_bytes = node.name().as_slice();
        let name = std::str::from_utf8(name_bytes).ok().map(|s| s.to_string());

        // Class-body member classification: `def f` inside a `class …`
        // body is a method (NPM); `def self.f` is a class method but
        // still NPM-counted. Legacy walker treats both the same.
        let parent_kind = self.kinds.last().cloned().unwrap_or(SpaceKind::Unit);
        let in_class = matches!(
            parent_kind,
            SpaceKind::Class | SpaceKind::Impl | SpaceKind::Interface | SpaceKind::Trait
        );
        if in_class {
            let is_public = name.as_deref().map(ruby_method_is_public).unwrap_or(true);
            self.current()
                .npm
                .record_method(ContainerKind::Class, is_public);
        }

        // `def` keyword as Halstead operator; method name as operand.
        self.record_halstead_op("def");
        if let Some(ref n) = name {
            self.record_halstead_operand_text("Identifier", n);
        }
        // Parameter list `(…)` and parameter separators `,` are
        // operators per the legacy classifier (LPAREN / COMMA arms).
        if let Some(params) = node.parameters() {
            self.record_halstead_op("(");
            // One `,` per separator: requireds.len()-1 + each subsequent
            // boundary. Approximate by counting total params and
            // emitting (count-1) commas if >1.
            let total = count_parameters(&params);
            if total > 1 {
                for _ in 1..total {
                    self.record_halstead_op(",");
                }
            }
        }

        self.open_space(SpaceKind::Function, start, end, name);
        // `def` is LLOC per the legacy Ruby `Loc` rule (`Method` arm).
        // Also pin the `end` keyword line as ploc so PLOC matches the
        // legacy tree-sitter walker (which saw the `end` keyword as a
        // separate visitable token).
        self.record_lloc(start, true);
        self.observe_optional_keyword_line(node.end_keyword_loc());

        // Parameters → nargs.
        let argc = node.parameters().map(|p| count_parameters(&p)).unwrap_or(0);
        self.current().nargs.record_function_args(argc);

        // Cognitive: function entry resets nesting/lambda. If this
        // method is nested inside another method, depth bumps by 1.
        let mut ctx = self.cognitive;
        let nested_in_function = self
            .kinds
            .iter()
            .rev()
            .skip(1)
            .any(|k| matches!(k, SpaceKind::Function));
        ctx.nesting = 0;
        ctx.lambda = 0;
        if nested_in_function {
            ctx.depth = ctx.depth.saturating_add(1);
        }
        let saved = self.cognitive;
        self.cognitive = ctx;

        // Walk body / parameter defaults via the default helper.
        visit_def_node(self, node);

        self.cognitive = saved;
        self.close_space();
    }

    fn visit_class_node(&mut self, node: &ClassNode<'pr>) {
        let loc = node.location();
        let start = u32::try_from(loc.start_offset()).unwrap_or(0);
        let end = u32::try_from(loc.end_offset()).unwrap_or(0);
        let name = std::str::from_utf8(node.name().as_slice())
            .ok()
            .map(|s| s.to_string());

        self.record_halstead_op("class");
        self.open_space(SpaceKind::Class, start, end, name);
        self.record_lloc(start, true);
        self.observe_keyword_line(&node.end_keyword_loc());
        visit_class_node(self, node);
        self.close_space();
    }

    fn visit_module_node(&mut self, node: &ModuleNode<'pr>) {
        let loc = node.location();
        let start = u32::try_from(loc.start_offset()).unwrap_or(0);
        let end = u32::try_from(loc.end_offset()).unwrap_or(0);
        let name = std::str::from_utf8(node.name().as_slice())
            .ok()
            .map(|s| s.to_string());

        // Modules act like classes for NPA / NPM aggregation in Ruby —
        // a module body can declare methods and ivars, both class-like
        // bookkeeping. The legacy walker aliased Module under Class for
        // the `scope_for` rule.
        self.record_halstead_op("module");
        self.open_space(SpaceKind::Class, start, end, name);
        self.record_lloc(start, true);
        self.observe_keyword_line(&node.end_keyword_loc());
        visit_module_node(self, node);
        self.close_space();
    }

    fn visit_singleton_class_node(&mut self, node: &SingletonClassNode<'pr>) {
        // `class << self` — singleton class scope. Treat as a class-like
        // space so methods inside count toward NPM (legacy walker did
        // not have a distinct kind, but tree-sitter exposed it as a
        // `singleton_class`; the rules class node treated it as a
        // class).
        let loc = node.location();
        let start = u32::try_from(loc.start_offset()).unwrap_or(0);
        let end = u32::try_from(loc.end_offset()).unwrap_or(0);
        self.open_space(SpaceKind::Class, start, end, None);
        visit_singleton_class_node(self, node);
        self.close_space();
    }

    fn visit_block_node(&mut self, node: &BlockNode<'pr>) {
        // A `BlockNode` whose immediate parent is a `LambdaNode` is the
        // lambda's body, not a separate closure. In prism the lambda is
        // expressed as `LambdaNode` with a `BlockNode`-shaped body, so
        // the `Visit::visit_lambda_node` opens the closure space and
        // sets `inside_lambda_body=true` before the block visit fires.
        if self.inside_lambda_body {
            // Skip the closure scaffolding; the lambda already opened
            // it. Just walk children.
            visit_block_node(self, node);
            return;
        }
        let loc = node.location();
        let start = u32::try_from(loc.start_offset()).unwrap_or(0);
        let end = u32::try_from(loc.end_offset()).unwrap_or(0);
        self.open_space(SpaceKind::Closure, start, end, None);
        let argc = node
            .parameters()
            .as_ref()
            .map(count_block_parameters)
            .unwrap_or(0);
        self.current().nargs.record_closure_args(argc);

        let mut ctx = self.cognitive;
        ctx.lambda = ctx.lambda.saturating_add(1);
        let saved = self.cognitive;
        self.cognitive = ctx;

        visit_block_node(self, node);

        self.cognitive = saved;
        self.close_space();
    }

    fn visit_lambda_node(&mut self, node: &LambdaNode<'pr>) {
        let loc = node.location();
        let start = u32::try_from(loc.start_offset()).unwrap_or(0);
        let end = u32::try_from(loc.end_offset()).unwrap_or(0);
        self.open_space(SpaceKind::Closure, start, end, None);
        let argc = node
            .parameters()
            .as_ref()
            .map(count_block_parameters)
            .unwrap_or(0);
        self.current().nargs.record_closure_args(argc);

        let mut ctx = self.cognitive;
        ctx.lambda = ctx.lambda.saturating_add(1);
        let saved = self.cognitive;
        self.cognitive = ctx;

        let saved_inside = self.inside_lambda_body;
        self.inside_lambda_body = true;
        visit_lambda_node(self, node);
        self.inside_lambda_body = saved_inside;

        self.cognitive = saved;
        self.close_space();
    }

    // ─── Conditionals (cyclomatic +1, cognitive nesting / +1) ──────────────

    fn visit_if_node(&mut self, node: &IfNode<'pr>) {
        // `IfNode` covers three forms: block `if … end`, modifier `x if y`,
        // and ternary `a ? b : c`. Distinguish via location options.
        let is_modifier = node.if_keyword_loc().is_some() && node.end_keyword_loc().is_none();
        let is_ternary = node.if_keyword_loc().is_none();

        self.record_lloc(
            u32::try_from(node.location().start_offset()).unwrap_or(0),
            true,
        );

        // Cyclomatic: +1 for any of the three forms (each is a decision
        // point per Sonar / McCabe).
        self.current().cyclomatic.record_decision();
        self.current().abc.record_condition();
        self.record_halstead_op(if is_ternary { "?:" } else { "if" });

        if is_modifier || is_ternary {
            // Modifier `x if y` and ternary `a ? b : c` add +1 cognitive
            // without nesting (per Sonar spec: trailing modifier sits on
            // a statement edge; ternary is a single-decision form).
            self.cognitive_increment_by_one();
        } else {
            // Block form: nesting structure.
            self.cognitive_increase_nesting();
            self.observe_optional_keyword_line(node.end_keyword_loc());
            self.cognitive.nesting += 1;
            visit_if_node(self, node);
            self.cognitive.nesting -= 1;
            return;
        }

        // For modifier and ternary: walk children inline (no nesting
        // bump). The default helper handles predicate + statements +
        // subsequent.
        visit_if_node(self, node);
    }

    fn visit_unless_node(&mut self, node: &UnlessNode<'pr>) {
        // `UnlessNode` covers block `unless … end` and modifier
        // `x unless y`. No ternary form.
        let is_modifier = node.end_keyword_loc().is_none();

        self.record_lloc(
            u32::try_from(node.location().start_offset()).unwrap_or(0),
            true,
        );
        self.current().cyclomatic.record_decision();
        self.current().abc.record_condition();
        self.record_halstead_op("unless");

        if is_modifier {
            self.cognitive_increment_by_one();
            visit_unless_node(self, node);
        } else {
            self.cognitive_increase_nesting();
            self.observe_optional_keyword_line(node.end_keyword_loc());
            self.cognitive.nesting += 1;
            visit_unless_node(self, node);
            self.cognitive.nesting -= 1;
        }
    }

    fn visit_while_node(&mut self, node: &WhileNode<'pr>) {
        let is_modifier = node.closing_loc().is_none();

        self.record_lloc(
            u32::try_from(node.location().start_offset()).unwrap_or(0),
            true,
        );
        self.current().cyclomatic.record_decision();
        self.current().abc.record_condition();
        self.record_halstead_op("while");

        if is_modifier {
            self.cognitive_increment_by_one();
            visit_while_node(self, node);
        } else {
            self.cognitive_increase_nesting();
            self.observe_optional_keyword_line(node.closing_loc());
            self.cognitive.nesting += 1;
            visit_while_node(self, node);
            self.cognitive.nesting -= 1;
        }
    }

    fn visit_until_node(&mut self, node: &UntilNode<'pr>) {
        let is_modifier = node.closing_loc().is_none();

        self.record_lloc(
            u32::try_from(node.location().start_offset()).unwrap_or(0),
            true,
        );
        self.current().cyclomatic.record_decision();
        self.current().abc.record_condition();
        self.record_halstead_op("until");

        if is_modifier {
            self.cognitive_increment_by_one();
            visit_until_node(self, node);
        } else {
            self.cognitive_increase_nesting();
            self.observe_optional_keyword_line(node.closing_loc());
            self.cognitive.nesting += 1;
            visit_until_node(self, node);
            self.cognitive.nesting -= 1;
        }
    }

    fn visit_for_node(&mut self, node: &ForNode<'pr>) {
        self.record_lloc(
            u32::try_from(node.location().start_offset()).unwrap_or(0),
            true,
        );
        self.current().cyclomatic.record_decision();
        self.current().abc.record_condition();
        self.record_halstead_op("for");
        self.cognitive_increase_nesting();
        // ForNode also has end_keyword_loc.
        self.cognitive.nesting += 1;
        visit_for_node(self, node);
        self.cognitive.nesting -= 1;
    }

    fn visit_case_node(&mut self, node: &CaseNode<'pr>) {
        // Classic `case x; when 1 then …; else …; end`. The `case`
        // itself is a structural branch (cognitive +1+nesting); each
        // `when` clause is a separate decision (+1 cyclomatic per
        // when, no nesting).
        self.record_lloc(
            u32::try_from(node.location().start_offset()).unwrap_or(0),
            true,
        );
        self.record_halstead_op("case");
        self.cognitive_increase_nesting();
        self.observe_keyword_line(&node.end_keyword_loc());
        self.cognitive.nesting += 1;
        visit_case_node(self, node);
        self.cognitive.nesting -= 1;
    }

    fn visit_when_node(&mut self, node: &WhenNode<'pr>) {
        // Each `when` clause adds a cyclomatic decision and an ABC
        // condition; cognitive is unchanged (cost paid by `case`).
        self.current().cyclomatic.record_decision();
        self.current().abc.record_condition();
        self.record_halstead_op("when");
        visit_when_node(self, node);
    }

    fn visit_case_match_node(&mut self, node: &CaseMatchNode<'pr>) {
        // Pattern-matching `case x; in pat then …; end`. Same shape as
        // CaseNode for our purposes — case bumps nesting once, each
        // `in` is a decision.
        self.record_lloc(
            u32::try_from(node.location().start_offset()).unwrap_or(0),
            true,
        );
        self.record_halstead_op("case");
        self.cognitive_increase_nesting();
        self.observe_keyword_line(&node.end_keyword_loc());
        self.cognitive.nesting += 1;
        visit_case_match_node(self, node);
        self.cognitive.nesting -= 1;
    }

    fn visit_in_node(&mut self, node: &InNode<'pr>) {
        self.current().cyclomatic.record_decision();
        self.current().abc.record_condition();
        self.record_halstead_op("in");
        visit_in_node(self, node);
    }

    fn visit_match_predicate_node(&mut self, node: &MatchPredicateNode<'pr>) {
        // `expr in pat` (one-line pattern test) — single decision.
        self.current().cyclomatic.record_decision();
        self.current().abc.record_condition();
        visit_match_predicate_node(self, node);
    }

    fn visit_else_node(&mut self, node: &ElseNode<'pr>) {
        // `else` (in `if`/`case`/`begin`): no cyclomatic increment
        // (the `if`/`case` already picked a branch), +1 cognitive
        // without nesting (per Sonar).
        self.cognitive_increment_by_one();
        self.record_halstead_op("else");
        visit_else_node(self, node);
    }

    fn visit_begin_node(&mut self, node: &BeginNode<'pr>) {
        // `begin ... end` block — not a decision in itself; only its
        // `rescue` / `else` / `ensure` children matter.
        self.record_lloc(
            u32::try_from(node.location().start_offset()).unwrap_or(0),
            true,
        );
        self.record_halstead_op("begin");
        self.observe_optional_keyword_line(node.end_keyword_loc());
        visit_begin_node(self, node);
    }

    fn visit_rescue_node(&mut self, node: &RescueNode<'pr>) {
        // `begin … rescue X => e; … end` — block-form rescue clause.
        // Cyclomatic +1 (each rescue is a separate execution path);
        // cognitive nesting bump (matches legacy `Rescue: nesting += 1`
        // rule).
        self.current().cyclomatic.record_decision();
        self.current().abc.record_condition();
        self.record_halstead_op("rescue");
        self.cognitive_increase_nesting();
        self.cognitive.nesting += 1;
        visit_rescue_node(self, node);
        self.cognitive.nesting -= 1;
    }

    fn visit_rescue_modifier_node(&mut self, node: &RescueModifierNode<'pr>) {
        // `expr rescue fallback` — postfix modifier form, distinct node
        // kind. Cyclomatic +1, cognitive +1 without nesting (modifier
        // sits on the trailing edge of the assignment).
        self.current().cyclomatic.record_decision();
        self.current().abc.record_condition();
        self.record_halstead_op("rescue");
        self.cognitive_increment_by_one();
        visit_rescue_modifier_node(self, node);
    }

    // ─── Logical short-circuit ─────────────────────────────────────────────

    fn visit_and_node(&mut self, node: &AndNode<'pr>) {
        // `a && b` / `a and b`. Each `&&` / `and` between two operands
        // is +1 cyclomatic and +1 ABC condition. Boolean-sequence
        // collapsing groups consecutive same-operator runs.
        self.current().cyclomatic.record_decision();
        self.current().abc.record_condition();
        self.record_halstead_op("&&");
        // Use the operator slice to label the boolean — `&&` and `and`
        // both collapse but legacy distinguishes them by token text.
        let op_text = std::str::from_utf8(node.operator_loc().as_slice()).unwrap_or("&&");
        let label = if op_text == "and" { "and" } else { "&&" };
        self.current().cognitive.observe_boolean(label);
        self.bool_depth = self.bool_depth.saturating_add(1);
        visit_and_node(self, node);
        self.bool_depth = self.bool_depth.saturating_sub(1);
    }

    fn visit_or_node(&mut self, node: &OrNode<'pr>) {
        self.current().cyclomatic.record_decision();
        self.current().abc.record_condition();
        self.record_halstead_op("||");
        let op_text = std::str::from_utf8(node.operator_loc().as_slice()).unwrap_or("||");
        let label = if op_text == "or" { "or" } else { "||" };
        self.current().cognitive.observe_boolean(label);
        self.bool_depth = self.bool_depth.saturating_add(1);
        visit_or_node(self, node);
        self.bool_depth = self.bool_depth.saturating_sub(1);
    }

    // ─── Calls, assignments, exits ─────────────────────────────────────────

    fn visit_call_node(&mut self, node: &CallNode<'pr>) {
        // LLOC: every call is a logical line per the legacy Ruby Loc
        // rule (`Call | Call2 | Call3 | Call4` arms).
        self.current().loc.observe_lloc();

        // Prism parses `a + b` as `CallNode { name: :+ }` — i.e. an
        // operator-method call. The legacy tree-sitter walker only
        // counted `call`/`command` (real `obj.method(args)` /
        // `puts foo` syntax) as ABC.B; arithmetic / comparison
        // operators were classified by the `Binary` node, not Call.
        // To preserve metric definitions: ABC.B counts dispatch ("we
        // hand control to a named method"), and `a + b` is dispatch in
        // theory but reads as a structural operator in practice.
        // Match the legacy classification: skip ABC.B for operator-
        // method calls; record them as Halstead operators only.
        let name_bytes = node.name().as_slice();
        let name_str = std::str::from_utf8(name_bytes).ok();
        let is_operator_call = name_str.is_some_and(is_ruby_operator_method);

        if !is_operator_call {
            self.current().abc.record_branch();
        }

        // Legacy walker counted `binary` (e.g. `a > 0`, `a == b`) as
        // ABC.C. In prism that maps to a CallNode with a comparison
        // operator name. Other binary operators (`+`, `-`, `*`, …) are
        // arithmetic, not conditions.
        if let Some(name) = name_str
            && is_ruby_comparison_method(name)
        {
            self.current().abc.record_condition();
        }

        if let Some(name) = name_str {
            if is_operator_call {
                self.record_halstead_op_text("call_op", name);
            } else {
                self.record_halstead_operand_text("Identifier", name);
            }
        }

        // Statement-shaped call → reset boolean sequence (a call at the
        // statement level is a fresh boolean context — matches legacy
        // `Statement: stats.boolean_seq.reset()`).
        // Heuristic: a call is at "statement level" if neither parent
        // is a logical operator. We approximate via bool_depth==0.
        if self.bool_depth == 0 {
            self.current().cognitive.boolean_seq.reset();
        }

        visit_call_node(self, node);
    }

    fn visit_yield_node(&mut self, node: &YieldNode<'pr>) {
        // `yield` — Ruby's coroutine yield. NOT counted as an exit per
        // legacy walker (matches Sonar: yield is a hand-off, not an
        // early return). It does count as a Halstead operator.
        self.record_halstead_op("yield");
        visit_yield_node(self, node);
    }

    fn visit_return_node(&mut self, node: &ReturnNode<'pr>) {
        self.current().nexit.record_exit();
        self.record_halstead_op("return");
        visit_return_node(self, node);
    }

    fn visit_break_node(&mut self, node: &BreakNode<'pr>) {
        self.current().nexit.record_exit();
        self.record_halstead_op("break");
        visit_break_node(self, node);
    }

    fn visit_next_node(&mut self, node: &NextNode<'pr>) {
        self.current().nexit.record_exit();
        self.record_halstead_op("next");
        visit_next_node(self, node);
    }

    fn visit_redo_node(&mut self, node: &RedoNode<'pr>) {
        self.current().nexit.record_exit();
        self.record_halstead_op("redo");
        visit_redo_node(self, node);
    }

    // ─── Variable writes (every form is a distinct prism node) ────────────

    fn visit_local_variable_write_node(&mut self, node: &LocalVariableWriteNode<'pr>) {
        self.current().loc.observe_lloc();
        self.current().abc.record_assignment();
        self.record_halstead_op("=");
        if let Ok(name) = std::str::from_utf8(node.name().as_slice()) {
            self.record_halstead_operand_text("Identifier", name);
        }
        visit_local_variable_write_node(self, node);
    }

    fn visit_local_variable_operator_write_node(
        &mut self,
        node: &LocalVariableOperatorWriteNode<'pr>,
    ) {
        // `x += 1`, `x *= 2`, etc. — augmented assignment.
        self.current().loc.observe_lloc();
        self.current().abc.record_assignment();
        self.record_halstead_op_text(
            "op_assign",
            std::str::from_utf8(node.binary_operator_loc().as_slice()).unwrap_or(""),
        );
        if let Ok(name) = std::str::from_utf8(node.name().as_slice()) {
            self.record_halstead_operand_text("Identifier", name);
        }
        visit_local_variable_operator_write_node(self, node);
    }

    fn visit_local_variable_and_write_node(&mut self, node: &LocalVariableAndWriteNode<'pr>) {
        // `x &&= y` — short-circuiting conditional assignment. Counts
        // as both an assignment AND a condition (the `&&` decides).
        self.current().loc.observe_lloc();
        self.current().abc.record_assignment();
        self.current().cyclomatic.record_decision();
        self.current().abc.record_condition();
        self.record_halstead_op("&&=");
        if let Ok(name) = std::str::from_utf8(node.name().as_slice()) {
            self.record_halstead_operand_text("Identifier", name);
        }
        visit_local_variable_and_write_node(self, node);
    }

    fn visit_local_variable_or_write_node(&mut self, node: &LocalVariableOrWriteNode<'pr>) {
        // `x ||= y` — defaulting assignment. Same accounting as `&&=`.
        self.current().loc.observe_lloc();
        self.current().abc.record_assignment();
        self.current().cyclomatic.record_decision();
        self.current().abc.record_condition();
        self.record_halstead_op("||=");
        if let Ok(name) = std::str::from_utf8(node.name().as_slice()) {
            self.record_halstead_operand_text("Identifier", name);
        }
        visit_local_variable_or_write_node(self, node);
    }

    fn visit_instance_variable_write_node(&mut self, node: &InstanceVariableWriteNode<'pr>) {
        self.current().loc.observe_lloc();
        self.current().abc.record_assignment();
        self.record_halstead_op("=");

        // Class-body @ivar assignment → NPA on the enclosing class.
        let parent_kind = self.kinds.last().cloned().unwrap_or(SpaceKind::Unit);
        if matches!(parent_kind, SpaceKind::Class | SpaceKind::Impl) {
            // Ruby ivars are non-public by convention (`@x` is private
            // until exposed via `attr_reader`).
            self.current()
                .npa
                .record_attribute(ContainerKind::Class, false);
        }
        if let Ok(name) = std::str::from_utf8(node.name().as_slice()) {
            self.record_halstead_operand_text("InstanceVariable", name);
        }
        visit_instance_variable_write_node(self, node);
    }

    fn visit_constant_write_node(&mut self, node: &ConstantWriteNode<'pr>) {
        self.current().loc.observe_lloc();
        self.current().abc.record_assignment();
        self.record_halstead_op("=");
        if let Ok(name) = std::str::from_utf8(node.name().as_slice()) {
            self.record_halstead_operand_text("Constant", name);
        }
        visit_constant_write_node(self, node);
    }

    fn visit_call_operator_write_node(&mut self, node: &CallOperatorWriteNode<'pr>) {
        // `obj.attr += 1` — receiver method op-assign.
        self.current().loc.observe_lloc();
        self.current().abc.record_assignment();
        self.record_halstead_op_text(
            "op_assign",
            std::str::from_utf8(node.binary_operator_loc().as_slice()).unwrap_or(""),
        );
        visit_call_operator_write_node(self, node);
    }

    fn visit_index_operator_write_node(&mut self, node: &IndexOperatorWriteNode<'pr>) {
        // `a[k] += 1` — index op-assign.
        self.current().loc.observe_lloc();
        self.current().abc.record_assignment();
        self.record_halstead_op_text(
            "op_assign",
            std::str::from_utf8(node.binary_operator_loc().as_slice()).unwrap_or(""),
        );
        visit_index_operator_write_node(self, node);
    }

    // ─── Halstead operands: variable reads, parameters, literals ───────────

    fn visit_local_variable_read_node(&mut self, node: &LocalVariableReadNode<'pr>) {
        if let Ok(name) = std::str::from_utf8(node.name().as_slice()) {
            self.record_halstead_operand_text("Identifier", name);
        }
    }

    fn visit_instance_variable_read_node(&mut self, node: &InstanceVariableReadNode<'pr>) {
        if let Ok(name) = std::str::from_utf8(node.name().as_slice()) {
            self.record_halstead_operand_text("InstanceVariable", name);
        }
    }

    fn visit_class_variable_read_node(&mut self, node: &ClassVariableReadNode<'pr>) {
        if let Ok(name) = std::str::from_utf8(node.name().as_slice()) {
            self.record_halstead_operand_text("ClassVariable", name);
        }
    }

    fn visit_global_variable_read_node(&mut self, node: &GlobalVariableReadNode<'pr>) {
        if let Ok(name) = std::str::from_utf8(node.name().as_slice()) {
            self.record_halstead_operand_text("GlobalVariable", name);
        }
    }

    fn visit_constant_read_node(&mut self, node: &ConstantReadNode<'pr>) {
        if let Ok(name) = std::str::from_utf8(node.name().as_slice()) {
            self.record_halstead_operand_text("Constant", name);
        }
    }

    fn visit_required_parameter_node(&mut self, node: &RequiredParameterNode<'pr>) {
        if let Ok(name) = std::str::from_utf8(node.name().as_slice()) {
            self.record_halstead_operand_text("Identifier", name);
        }
    }

    fn visit_optional_parameter_node(&mut self, node: &OptionalParameterNode<'pr>) {
        if let Ok(name) = std::str::from_utf8(node.name().as_slice()) {
            self.record_halstead_operand_text("Identifier", name);
        }
        visit_optional_parameter_node(self, node);
    }

    fn visit_integer_node(&mut self, _node: &IntegerNode<'pr>) {
        self.record_halstead_operand_text("Integer", "");
    }

    fn visit_float_node(&mut self, _node: &FloatNode<'pr>) {
        self.record_halstead_operand_text("Float", "");
    }

    fn visit_string_node(&mut self, _node: &StringNode<'pr>) {
        self.record_halstead_operand_text("String", "");
    }

    fn visit_symbol_node(&mut self, _node: &SymbolNode<'pr>) {
        self.record_halstead_operand_text("Symbol", "");
    }

    fn visit_true_node(&mut self, _node: &TrueNode<'pr>) {
        self.record_halstead_operand_text("True", "true");
    }

    fn visit_false_node(&mut self, _node: &FalseNode<'pr>) {
        self.record_halstead_operand_text("False", "false");
    }

    fn visit_nil_node(&mut self, _node: &NilNode<'pr>) {
        self.record_halstead_operand_text("Nil", "nil");
    }

    fn visit_self_node(&mut self, _node: &SelfNode<'pr>) {
        self.record_halstead_operand_text("Self", "self");
    }

    // ─── Generic enter hooks for PLOC accounting ───────────────────────────
    //
    // The legacy Ruby `Loc::compute` ran a `_ => ploc.lines.insert(start)`
    // arm for every non-container, non-comment node — meaning PLOC is
    // the SET of rows that any code node starts on. We mirror that by
    // observing `code_line` on every branch / leaf node visit. The set
    // dedupes by row so it's idempotent.

    fn visit_branch_node_enter(&mut self, node: Node<'pr>) {
        let start = u32::try_from(node.location().start_offset()).unwrap_or(0);
        let row = self.line_index.line_at(start).saturating_sub(1);
        self.current().loc.observe_code_line(row);
    }

    fn visit_leaf_node_enter(&mut self, node: Node<'pr>) {
        let start = u32::try_from(node.location().start_offset()).unwrap_or(0);
        let row = self.line_index.line_at(start).saturating_sub(1);
        self.current().loc.observe_code_line(row);
    }
}

// ─── Helpers ───────────────────────────────────────────────────────────────

fn count_parameters(p: &ruby_prism::ParametersNode<'_>) -> u32 {
    let mut total: u32 = 0;
    total += u32::try_from(p.requireds().len()).unwrap_or(0);
    total += u32::try_from(p.optionals().len()).unwrap_or(0);
    if p.rest().is_some() {
        total += 1;
    }
    total += u32::try_from(p.posts().len()).unwrap_or(0);
    total += u32::try_from(p.keywords().len()).unwrap_or(0);
    if p.keyword_rest().is_some() {
        total += 1;
    }
    if p.block().is_some() {
        total += 1;
    }
    total
}

fn count_block_parameters(node: &Node<'_>) -> u32 {
    // BlockNode::parameters() / LambdaNode::parameters() return a
    // generic Node that may be `BlockParametersNode` (which wraps
    // `ParametersNode`), `NumberedParametersNode`, or `ItParametersNode`.
    if let Some(bp) = node.as_block_parameters_node() {
        return bp.parameters().as_ref().map(count_parameters).unwrap_or(0);
    }
    if let Some(np) = node.as_numbered_parameters_node() {
        return u32::from(np.maximum());
    }
    if node.as_it_parameters_node().is_some() {
        return 1;
    }
    0
}

fn ruby_method_is_public(name: &str) -> bool {
    // Ruby has no syntactic public/protected/private modifier on `def`
    // itself; visibility is set by `private` / `protected` calls in the
    // class body. Without semantic flow analysis we treat every `def`
    // as public, matching the legacy walker's default.
    !name.starts_with('_') || name.starts_with("__") && name.ends_with("__")
}

fn is_ruby_comparison_method(name: &str) -> bool {
    // Comparison operator method names — Ruby parses `a < b` as a call
    // to `:<` on `a`, etc. These are ABC.C conditions per legacy.
    matches!(name, "==" | "!=" | "<" | ">" | "<=" | ">=" | "<=>" | "===")
}

fn is_ruby_operator_method(name: &str) -> bool {
    // Names a CallNode can carry when the call was parsed from
    // Ruby operator syntax (`a + b` parses as CallNode { name: :+ }).
    matches!(
        name,
        "+" | "-"
            | "*"
            | "/"
            | "%"
            | "**"
            | "=="
            | "!="
            | "<"
            | ">"
            | "<="
            | ">="
            | "<=>"
            | "==="
            | "<<"
            | ">>"
            | "&"
            | "|"
            | "^"
            | "~"
            | "!"
            | "+@"
            | "-@"
            | "[]"
            | "[]="
    )
}
