use super::*;

#[test]
fn extracts_rails_routes() {
    let src = r#"
Rails.application.routes.draw do
  resources :posts
  get '/foo' => 'foos#show'
  post '/login', to: 'sessions#create'
  root to: 'home#index'
end
"#;
    let out = extract("repo", Path::new("config/routes.rb"), src).unwrap();
    let routes: Vec<&Symbol> = out
        .symbols
        .iter()
        .filter(|s| matches!(s.kind, Kind::Route))
        .collect();
    let names: Vec<&str> = routes.iter().map(|s| s.name.as_str()).collect();
    // resources :posts → 7 routes
    assert!(names.iter().any(|n| n.starts_with("GET /posts")));
    assert!(names.iter().any(|n| n.starts_with("POST /posts")));
    assert!(names.iter().any(|n| n == &"DELETE /posts/:id"));
    // explicit get
    assert!(names.contains(&"GET /foo"));
    // explicit post with to:
    assert!(names.contains(&"POST /login"));
    // root
    assert!(names.contains(&"GET /"));
}

#[test]
fn rails_namespace_prefixes_paths() {
    let src = r#"
Rails.application.routes.draw do
  namespace :admin do
    resources :users
    get '/dashboard' => 'admin#dashboard'
  end
  scope '/api' do
    get '/health' => 'health#index'
  end
end
"#;
    let out = extract("repo", Path::new("config/routes.rb"), src).unwrap();
    let names: Vec<&str> = out
        .symbols
        .iter()
        .filter(|s| matches!(s.kind, Kind::Route))
        .map(|s| s.name.as_str())
        .collect();
    assert!(
        names.contains(&"GET /admin/users"),
        "missing /admin/users: {names:?}"
    );
    assert!(names.contains(&"DELETE /admin/users/:id"));
    assert!(names.contains(&"GET /admin/dashboard"));
    assert!(names.contains(&"GET /api/health"));
}

#[test]
fn rails_member_collection_mount_options() {
    let src = r#"
Rails.application.routes.draw do
  resources :posts do
    member do
      get :preview
      post :publish
    end
    collection do
      get :search
    end
  end
  get '/foo' => 'foos#show', as: :foo_link
  post '/admin', controller: 'admin', action: 'create'
  mount Sidekiq::Web => '/sidekiq'
end
"#;
    let out = extract("repo", Path::new("config/routes.rb"), src).unwrap();
    let routes: Vec<&Symbol> = out
        .symbols
        .iter()
        .filter(|s| matches!(s.kind, Kind::Route))
        .collect();
    let names: Vec<&str> = routes.iter().map(|s| s.name.as_str()).collect();
    // member block routes
    assert!(names.contains(&"GET /posts/:id/preview"), "{names:?}");
    assert!(names.contains(&"POST /posts/:id/publish"));
    // collection block route
    assert!(names.contains(&"GET /posts/search"));
    // standard 7 still present
    assert!(names.contains(&"GET /posts"));
    assert!(names.contains(&"DELETE /posts/:id"));
    // mount route
    assert!(names.contains(&"MOUNT /sidekiq"));
    // `as:` recorded in handler doc.
    let foo_route = routes.iter().find(|s| s.name == "GET /foo").unwrap();
    assert!(foo_route.doc.contains("as=foo_link"));
    // controller:/action: synthesised handler.
    let admin = routes.iter().find(|s| s.name == "POST /admin").unwrap();
    assert!(admin.doc.contains("admin#create"));
}

#[test]
fn ruby_full_coverage_extras() {
    let src = r#"
TOP_CONST = 42

module HasName
end

class User
  include HasName
  extend HasName

  attr_accessor :name, :email
  attr_reader :id

  def public_method; end

  private

  def secret; end

  public

  def again_public; end
end
"#;
    let out = extract("repo", Path::new("user.rb"), src).unwrap();
    let names: Vec<&str> = out.symbols.iter().map(|s| s.name.as_str()).collect();
    // Top-level constant
    assert!(names.contains(&"TOP_CONST"), "TOP_CONST missing: {names:?}");
    // attr_accessor → name, name=, email, email=
    for n in ["name", "name=", "email", "email=", "id"] {
        assert!(names.contains(&n), "attr method {n} missing: {names:?}");
    }
    // include / extend → Extends edges to HasName
    let extends_count = out
        .edges
        .iter()
        .filter(|e| matches!(e.kind, EdgeKind::Extends) && e.dst == "HasName")
        .count();
    assert_eq!(
        extends_count, 2,
        "expected 2 mixin edges, got {extends_count}"
    );
    // Visibility flip: secret should be private; again_public should be public.
    let secret = out.symbols.iter().find(|s| s.name == "secret").unwrap();
    assert!(!secret.is_public, "secret should be private");
    let again = out
        .symbols
        .iter()
        .find(|s| s.name == "again_public")
        .unwrap();
    assert!(again.is_public, "again_public should be public");
    let pm = out
        .symbols
        .iter()
        .find(|s| s.name == "public_method")
        .unwrap();
    assert!(pm.is_public, "public_method should be public");
}

#[test]
fn ruby_block_arg_and_struct_and_alias() {
    let src = r#"
class Foo
  def list_names; @items.map(&:upcase); end

  alias_method :rename, :name
end

Point = Struct.new(:x, :y)
"#;
    let out = extract("repo", Path::new("foo.rb"), src).unwrap();
    // &:upcase → Calls edge with dst=upcase, conf 0.7
    let block_call = out.edges.iter().find(|e| {
        matches!(e.kind, EdgeKind::Calls) && e.dst == "upcase" && (e.confidence - 0.7).abs() < 0.001
    });
    assert!(block_call.is_some(), "&:upcase not extracted");

    // alias_method :rename, :name → emit `rename` Method, Calls rename → name
    let names: Vec<&str> = out.symbols.iter().map(|s| s.name.as_str()).collect();
    assert!(names.contains(&"rename"), "alias rename not emitted");
    let alias_call = out
        .edges
        .iter()
        .find(|e| matches!(e.kind, EdgeKind::Calls) && e.dst == "name" && e.confidence == 1.0);
    assert!(alias_call.is_some(), "alias_method Calls edge missing");

    // Struct.new accessors: x, x=, y, y=
    for n in ["x", "x=", "y", "y="] {
        assert!(names.contains(&n), "Struct accessor {n} missing: {names:?}");
    }
}

#[test]
fn ruby_define_method_and_concern_included() {
    let src = r#"
class C
  define_method :foo do; end
  define_method "bar" do; end
end

module Likeable
  extend ActiveSupport::Concern

  included do
    has_many :likes
    scope :liked, -> { where(liked: true) }
    before_save :touch_timestamps
  end
end
"#;
    let out = extract("repo", Path::new("model.rb"), src).unwrap();
    let names: Vec<&str> = out.symbols.iter().map(|s| s.name.as_str()).collect();
    // define_method literal names → Method symbols
    for n in ["foo", "bar"] {
        assert!(names.contains(&n), "define_method {n} missing: {names:?}");
    }
    // included do {} — its body should attach to the Likeable module:
    // - has_many :likes → Extends edge from Likeable to Like
    // - scope :liked → emit `liked` Method
    // - before_save → Calls edge with conf 0.9
    assert!(names.contains(&"liked"), "scope inside included missing");
    let extends_dsts: Vec<&str> = out
        .edges
        .iter()
        .filter(|e| matches!(e.kind, EdgeKind::Extends))
        .map(|e| e.dst.as_str())
        .collect();
    assert!(
        extends_dsts.contains(&"Like"),
        "Like association missing: {extends_dsts:?}"
    );
    let cb = out.edges.iter().any(|e| {
        matches!(e.kind, EdgeKind::Calls)
            && e.dst == "touch_timestamps"
            && (e.confidence - 0.9).abs() < 0.001
    });
    assert!(cb, "callback inside included missing");
}

#[test]
fn rails_implicit_controller_actions() {
    let src = r#"
class PostsController < ApplicationController
  def index; end
  def show; end
  def new; end
  def create; end
  def edit; end
  def update; end
  def destroy; end
  def preview; end  # custom action
end

class BlogPostsController < ApplicationController
  def show; end
end
"#;
    let out = extract("repo", Path::new("posts_controller.rb"), src).unwrap();
    let route_names: Vec<&str> = out
        .symbols
        .iter()
        .filter(|s| matches!(s.kind, Kind::Route))
        .map(|s| s.name.as_str())
        .collect();
    // Only the standard 7 RESTful actions imply a route. Custom actions
    // (e.g. `preview`) are NOT auto-mapped — they must be declared in
    // routes.rb. This avoids classifying every controller helper /
    // callback / private method as a phantom route.
    for r in [
        "GET /posts",
        "GET /posts/:id",
        "GET /posts/new",
        "POST /posts",
        "GET /posts/:id/edit",
        "PATCH /posts/:id",
        "DELETE /posts/:id",
    ] {
        assert!(
            route_names.contains(&r),
            "implicit route {r} missing: {route_names:?}"
        );
    }
    // Custom action must NOT be implicit-routed.
    assert!(
        !route_names.contains(&"GET /posts/preview"),
        "custom controller action must not be auto-routed (over-claim regression): \
         {route_names:?}"
    );
    // Snake-case multi-word controller: BlogPostsController → /blog_posts.
    assert!(
        route_names.contains(&"GET /blog_posts/:id"),
        "snake-cased multi-word controller missing"
    );
}

#[test]
fn rails_redirect_mailbox_filter_scope() {
    let src = r#"
Rails.application.routes.draw do
  get '/old', to: redirect('/new')
end

class PostsController < ApplicationController
  before_action :authenticate, only: [:show, :edit]
  before_action :load_post, except: [:index]

  def show; end
  def edit; end
  def index; end
end

class InboundMailbox < ApplicationMailbox
  routing /^support@/i => :support
  routing /^sales@/i => :sales

  def support; end
  def sales; end
end
"#;
    let out = extract("repo", Path::new("app.rb"), src).unwrap();
    let names: Vec<&str> = out.symbols.iter().map(|s| s.name.as_str()).collect();

    // Redirect target captured in route handler doc.
    let route = out.symbols.iter().find(|s| s.name == "GET /old").unwrap();
    assert!(
        route.doc.contains("redirect:/new"),
        "redirect target missing: {}",
        route.doc
    );

    // before_action filter scope marker symbols.
    assert!(
        names.contains(&"before_action_authenticate"),
        "filter marker missing"
    );
    let auth = out
        .symbols
        .iter()
        .find(|s| s.name == "before_action_authenticate")
        .unwrap();
    assert!(
        auth.doc.contains("only=[show,edit]"),
        "filter scope missing: {}",
        auth.doc
    );
    let load = out
        .symbols
        .iter()
        .find(|s| s.name == "before_action_load_post")
        .unwrap();
    assert!(load.doc.contains("except=[index]"));

    // ActionMailbox routing → MBOX routes.
    let route_names: Vec<&str> = out
        .symbols
        .iter()
        .filter(|s| matches!(s.kind, Kind::Route))
        .map(|s| s.name.as_str())
        .collect();
    assert!(
        route_names
            .iter()
            .any(|r| r.starts_with("MBOX") && r.contains("support")),
        "MBOX route missing: {route_names:?}"
    );
}

#[test]
fn rails_concerns_helpers_and_mailer() {
    let src = r#"
Rails.application.routes.draw do
  concern :commentable do
    resources :comments
  end
  resources :posts do
    concerns :commentable
  end
  direct(:browse) { |x| x }
  resolve('Foo') { |f| f }
end

class WelcomeMailer < ApplicationMailer
  def greet; end
  def reminder; end
end
"#;
    let out = extract("repo", Path::new("app.rb"), src).unwrap();
    let names: Vec<&str> = out.symbols.iter().map(|s| s.name.as_str()).collect();

    // Concern definition → Module symbol named `commentable`.
    let commentable = out
        .symbols
        .iter()
        .find(|s| s.name == "commentable" && matches!(s.kind, Kind::Module))
        .expect("concern Module symbol missing");
    assert!(
        commentable.qname.starts_with("concerns."),
        "qname: {}",
        commentable.qname
    );

    // direct → browse_url / resolve('Foo') → foo_url
    assert!(
        names.contains(&"browse_url"),
        "browse_url missing: {names:?}"
    );
    assert!(names.contains(&"foo_url"), "foo_url missing");

    // Mailer routes
    let route_names: Vec<&str> = out
        .symbols
        .iter()
        .filter(|s| matches!(s.kind, Kind::Route))
        .map(|s| s.name.as_str())
        .collect();
    assert!(
        route_names.contains(&"MAIL /WelcomeMailer/greet"),
        "Mailer route missing: {route_names:?}"
    );
    assert!(route_names.contains(&"MAIL /WelcomeMailer/reminder"));
}

#[test]
fn refinements_tag_inner_methods() {
    let src = r#"
module M
  refine String do
    def shout
      upcase + "!"
    end
  end
end
"#;
    let out = extract("repo", Path::new("m.rb"), src).unwrap();
    let shout = out.symbols.iter().find(|s| s.name == "shout").unwrap();
    assert!(
        shout.doc.contains("@refinement String"),
        "refinement marker missing: {:?}",
        shout.doc
    );
}

#[test]
fn rails_5_6_7_extras() {
    let src = r#"
Rails.application.routes.draw do
  controller :products do
    get :show
    post :create
  end
end

class MyJob < ApplicationJob
  def perform; end
end

class WelcomeMailer < ApplicationMailer
  def greet; end
end

class Post < ApplicationRecord
  default_scope { where(active: true) }
  has_one_attached :avatar
  has_many_attached :photos
  encrypts :ssn
  normalizes :email, with: ->(v) { v.downcase }
end
"#;
    let out = extract("repo", Path::new("app.rb"), src).unwrap();
    let names: Vec<&str> = out.symbols.iter().map(|s| s.name.as_str()).collect();

    // controller block routes
    assert!(
        names.contains(&"GET /products/show"),
        "controller block GET missing: {names:?}"
    );
    assert!(names.contains(&"POST /products/create"));

    // ActiveJob role tag
    let job = out.symbols.iter().find(|s| s.name == "MyJob").unwrap();
    assert!(job.doc.contains("@job"), "@job tag missing on MyJob");
    // ActionMailer role tag
    let mailer = out
        .symbols
        .iter()
        .find(|s| s.name == "WelcomeMailer")
        .unwrap();
    assert!(mailer.doc.contains("@mailer"));
    // ApplicationRecord tag
    let post = out.symbols.iter().find(|s| s.name == "Post").unwrap();
    assert!(post.doc.contains("@record"));

    // default_scope emitted as Method
    assert!(
        names.contains(&"default_scope"),
        "default_scope method missing"
    );

    // Attachments → avatar, avatar=, photos, photos=
    for n in ["avatar", "avatar=", "photos", "photos="] {
        assert!(names.contains(&n), "attachment {n} missing");
    }

    // encrypts/normalizes → markers
    assert!(names.contains(&"encrypts_ssn"), "encrypts marker missing");
    assert!(
        names.contains(&"normalizes_email"),
        "normalizes marker missing"
    );
}

#[test]
fn rails_associations_callbacks_validations_scopes() {
    let src = r#"
class Post < ApplicationRecord
  belongs_to :user
  has_many :comments
  has_many :tags, through: :taggings
  has_one :cover_image
  has_and_belongs_to_many :categories

  before_save :normalize_title
  after_create :send_notification
  before_action :authenticate

  validates :title, presence: true, length: { minimum: 3 }
  validates :slug, uniqueness: true

  scope :published, -> { where(published: true) }
  scope :recent, -> { order(created_at: :desc) }
end
"#;
    let out = extract("repo", Path::new("post.rb"), src).unwrap();
    let names: Vec<&str> = out.symbols.iter().map(|s| s.name.as_str()).collect();

    // Associations → Extends edges to model classes.
    let extends_dsts: Vec<&str> = out
        .edges
        .iter()
        .filter(|e| matches!(e.kind, EdgeKind::Extends))
        .map(|e| e.dst.as_str())
        .collect();
    for dst in [
        "User",
        "Comment",
        "Tag",
        "CoverImage",
        "Category",
        "ApplicationRecord",
    ] {
        assert!(
            extends_dsts.contains(&dst),
            "association {dst} missing: {extends_dsts:?}"
        );
    }

    // Callbacks → Calls edges (conf 0.9).
    let callbacks: Vec<&str> = out
        .edges
        .iter()
        .filter(|e| matches!(e.kind, EdgeKind::Calls) && (e.confidence - 0.9).abs() < 0.001)
        .map(|e| e.dst.as_str())
        .collect();
    for cb in ["normalize_title", "send_notification", "authenticate"] {
        assert!(
            callbacks.contains(&cb),
            "callback {cb} missing: {callbacks:?}"
        );
    }

    // Validations → emit `validates_title`, `validates_slug` Symbols.
    for v in ["validates_title", "validates_slug"] {
        assert!(names.contains(&v), "validation {v} missing");
    }

    // Scopes → emit `published`, `recent` Method Symbols.
    for s in ["published", "recent"] {
        assert!(names.contains(&s), "scope {s} missing");
    }
}

#[test]
fn extracts_class_method() {
    let src = r#"
class Animal
  def speak
    puts "..."
  end
end

class Dog < Animal
  def speak
    bark
  end
end
"#;
    let out = extract("repo", Path::new("zoo.rb"), src).unwrap();
    let names: Vec<&str> = out.symbols.iter().map(|s| s.name.as_str()).collect();
    for expected in ["Animal", "Dog", "speak"] {
        assert!(names.contains(&expected), "missing {expected}: {names:?}");
    }
    assert!(
        out.edges
            .iter()
            .any(|e| matches!(e.kind, EdgeKind::Extends))
    );
}

/// Cover the four most recent Ruby/Rails extractor features:
/// 1. `@variadic(...)` doc tag from `*args` / `**kw` / `&blk` params
/// 2. Type-aware dispatch — `var = Foo.new` upgrades `var.method` →
///    `Foo.method` at conf 0.7
/// 3. Rails `before_action only:/except:` per-action `Calls` expansion
///    that uses *real* `def` names (not the REST 7 fallback)
/// 4. `/posts.:format` constraint split (path stripped, format param
///    recorded in route doc)
#[test]
fn ruby_rails_recent_features() {
    let src = r#"
Rails.application.routes.draw do
  get '/posts.:format', to: 'posts#index'
end

class PostsController < ApplicationController
  before_action :authenticate, only: [:show]
  before_action :load_post, except: [:archive]

  def show; end
  def edit; end
  def index; end
  def archive; end
  def restore; end
end

class Calculator
  def add(*nums, **opts, &blk); end
end

class Worker
  def run
    repo = Repository.new
    repo.fetch
  end
end
"#;
    let out = extract("repo", Path::new("app.rb"), src).unwrap();

    // 1. @variadic on Calculator#add (splat + double_splat + block_param).
    let add = out
        .symbols
        .iter()
        .find(|s| s.name == "add")
        .expect("add method missing");
    assert!(
        add.doc.contains("@variadic"),
        "@variadic tag missing: {:?}",
        add.doc
    );
    for marker in ["splat", "double_splat", "block_param"] {
        assert!(
            add.doc.contains(marker),
            "{marker} marker missing: {:?}",
            add.doc
        );
    }

    // 2. Type-aware dispatch: `repo = Repository.new; repo.fetch` →
    //    `Repository.fetch` at confidence 0.7.
    let typed_call = out.edges.iter().find(|e| {
        matches!(e.kind, EdgeKind::Calls)
            && e.dst == "Repository.fetch"
            && (e.confidence - 0.7).abs() < 1e-3
    });
    assert!(
        typed_call.is_some(),
        "Repository.fetch typed-dispatch edge missing. Got: {:?}",
        out.edges
            .iter()
            .filter(|e| matches!(e.kind, EdgeKind::Calls) && e.dst.contains("fetch"))
            .map(|e| (&e.dst, e.confidence))
            .collect::<Vec<_>>()
    );

    // 3a. before_action :authenticate, only: [:show] →
    //     `PostsController.show` → `authenticate` Calls edge.
    let show_id = out
        .symbols
        .iter()
        .find(|s| s.name == "show" && s.qname.contains("PostsController"))
        .map(|s| s.id.clone())
        .expect("PostsController.show symbol missing");
    let auth_edge = out
        .edges
        .iter()
        .find(|e| matches!(e.kind, EdgeKind::Calls) && e.src == show_id && e.dst == "authenticate");
    assert!(
        auth_edge.is_some(),
        "before_action only: per-action edge missing: show → authenticate"
    );

    // 3b. before_action :load_post, except: [:archive] uses real `def`
    //     names: should hit `restore` (custom action) but not `archive`.
    let load_callers: Vec<&str> = out
        .edges
        .iter()
        .filter(|e| matches!(e.kind, EdgeKind::Calls) && e.dst == "load_post")
        .filter_map(|e| {
            out.symbols
                .iter()
                .find(|s| s.id == e.src)
                .map(|s| s.name.as_str())
        })
        .collect();
    assert!(
        load_callers.contains(&"restore"),
        "custom action `restore` missing from except: expansion: {load_callers:?}"
    );
    assert!(
        !load_callers.contains(&"archive"),
        "excluded action `archive` should NOT appear: {load_callers:?}"
    );

    // 4. Format constraint: `/posts.:format` → name strips `.:format`,
    //    doc carries the format param.
    let route = out
        .symbols
        .iter()
        .find(|s| matches!(s.kind, Kind::Route) && s.name.starts_with("GET /posts"))
        .expect("GET /posts route missing");
    assert_eq!(
        route.name, "GET /posts",
        "format suffix should be stripped from path: got {}",
        route.name
    );
    assert!(
        route.doc.contains("format=:format"),
        "format=:format marker missing in doc: {}",
        route.doc
    );
}

#[test]
fn rails_resources_only_filters_actions() {
    // AD6 fix: `resources :posts, only: [:index, :show]` must emit ONLY
    // those two routes from routes.rb, not all 7. Without the fix the
    // implicit standard set was emitted regardless of `:only`.
    let src = r#"
Rails.application.routes.draw do
  resources :posts, only: [:index, :show]
  resources :tags, except: [:destroy]
end
"#;
    let out = extract("repo", Path::new("config/routes.rb"), src).unwrap();
    let route_names: Vec<&str> = out
        .symbols
        .iter()
        .filter(|s| matches!(s.kind, Kind::Route))
        .map(|s| s.name.as_str())
        .collect();
    // posts: only index + show emitted.
    assert!(route_names.contains(&"GET /posts"));
    assert!(route_names.contains(&"GET /posts/:id"));
    assert!(
        !route_names.contains(&"POST /posts"),
        "create not in :only — must not emit. Got: {route_names:?}"
    );
    assert!(
        !route_names.contains(&"DELETE /posts/:id"),
        "destroy not in :only — must not emit"
    );
    // tags: all except destroy.
    assert!(route_names.contains(&"GET /tags"));
    assert!(route_names.contains(&"POST /tags"));
    assert!(
        !route_names.contains(&"DELETE /tags/:id"),
        "destroy in :except — must not emit"
    );
}

#[test]
fn rails_implicit_controller_routes_tagged_implicit() {
    // AD6 partial: routes from `emit_implicit_controller_routes` are
    // tagged `@implicit` so consumers can suppress them when routes.rb
    // (authoritative) is also indexed.
    let src = r#"
class PostsController < ApplicationController
  def index; end
  def show; end
end
"#;
    let out = extract("repo", Path::new("posts_controller.rb"), src).unwrap();
    let routes: Vec<&Symbol> = out
        .symbols
        .iter()
        .filter(|s| matches!(s.kind, Kind::Route))
        .collect();
    assert!(!routes.is_empty(), "expected implicit controller routes");
    for r in routes {
        assert!(
            r.doc.contains("@implicit"),
            "implicit controller route must be tagged @implicit; got doc={:?}",
            r.doc
        );
    }
}

#[test]
fn rails_resources_routes_tagged_at_resources() {
    // AD6 over-prune fix: routes emitted from `resources :foo` get
    // `@resources` tag. Custom one-off routes (`get '/posts/foo', ...`)
    // do NOT — only `@resources` triggers comprehensive pruning of
    // implicit controller routes.
    let src = r#"
Rails.application.routes.draw do
  resources :posts
  get '/posts/preview', to: 'posts#preview'
end
"#;
    let out = extract("repo", Path::new("config/routes.rb"), src).unwrap();
    let routes: Vec<&Symbol> = out
        .symbols
        .iter()
        .filter(|s| matches!(s.kind, Kind::Route))
        .collect();
    let resources_count = routes
        .iter()
        .filter(|r| r.doc.contains("@resources"))
        .count();
    let custom_count = routes
        .iter()
        .filter(|r| r.name == "GET /posts/preview")
        .count();
    assert!(
        resources_count >= 7,
        "expected ≥7 @resources-tagged routes for resources :posts; got {resources_count}"
    );
    assert_eq!(
        custom_count, 1,
        "expected 1 custom route for /posts/preview"
    );
    for r in &routes {
        if r.name == "GET /posts/preview" {
            assert!(
                !r.doc.contains("@resources"),
                "custom one-off route must NOT carry @resources tag; got doc={:?}",
                r.doc
            );
            assert!(
                !r.doc.contains("@implicit"),
                "custom route is not implicit either; got doc={:?}",
                r.doc
            );
        }
    }
}

#[test]
fn rails_resources_multi_resource_shorthand_tags_each() {
    // Multi-resource shorthand fix: `resources :posts, :tags` must
    // emit @resources-tagged routes for BOTH resources, not just the
    // first. The prior implementation captured only the first symbol
    // and silently dropped subsequent ones — wave-4's @resources tag
    // then asymmetrically masked the over-claim (`posts` deduplicated,
    // `tags` did not).
    let src = r#"
Rails.application.routes.draw do
  resources :posts, :tags, only: [:index, :show]
end
"#;
    let out = extract("repo", Path::new("config/routes.rb"), src).unwrap();
    let routes: Vec<&Symbol> = out
        .symbols
        .iter()
        .filter(|s| matches!(s.kind, Kind::Route))
        .collect();
    let names: Vec<&str> = routes.iter().map(|s| s.name.as_str()).collect();
    // Both resources emit `index` + `show` routes (filter applied to all).
    for r in ["GET /posts", "GET /posts/:id", "GET /tags", "GET /tags/:id"] {
        assert!(names.contains(&r), "missing {r}: {names:?}");
    }
    // Both must carry @resources tag.
    for r in routes {
        assert!(
            r.doc.contains("@resources"),
            "all multi-resource routes must be tagged @resources; got doc={:?}",
            r.doc
        );
    }
    // Filter applied: no destroy emitted for either resource.
    assert!(
        !names.contains(&"DELETE /posts/:id"),
        "destroy filtered out by :only — got {names:?}"
    );
    assert!(
        !names.contains(&"DELETE /tags/:id"),
        "destroy filtered out for tags too"
    );
}

#[test]
fn rails_namespaced_controller_emits_bare_resource_handler() {
    // AD6 namespace fix: `Admin::PostsController` must emit handler
    // `posts#index` (bare resource) so it joins against routes.rb's
    // `namespace :admin do; resources :posts; end` which also emits
    // `posts#index`. Without the fix the handler became
    // `admin::_posts#index` (pluralize_snake didn't understand `::`).
    let src = r#"
class Admin::PostsController < ApplicationController
  def index; end
  def show; end
end
"#;
    let out = extract("repo", Path::new("admin/posts_controller.rb"), src).unwrap();
    let routes: Vec<&Symbol> = out
        .symbols
        .iter()
        .filter(|s| matches!(s.kind, Kind::Route))
        .collect();
    assert!(!routes.is_empty(), "expected implicit controller routes");
    for r in routes {
        assert!(
            r.doc.contains("handler=posts#"),
            "namespaced controller must emit bare-resource handler `posts#…`; \
             got doc={:?}",
            r.doc
        );
        assert!(
            !r.doc.contains("admin"),
            "handler must not include the namespace prefix; got doc={:?}",
            r.doc
        );
    }
}
