use std::collections::BTreeMap;

use serde::Serialize;
use serde_json::{json, Value};

use droidsaw_dex::DexFile;
use droidsaw_dex::decode::parse_class_data;
use droidsaw_dex::xrefs::{method_key_for_idx, MethodKey, Xrefs};

use crate::context::CrossLayerContext;

use super::meta;

const ACCESS_FLAG_NATIVE: u32 = 0x100;
const ACCESS_FLAG_ABSTRACT: u32 = 0x400;

#[derive(Serialize)]
struct FridaHook {
    layer: String,
    function: String,
    function_id: u32,
    matched_string: String,
    hook: String,
}

/// Slice a smali proto `(params)ret` and return the `params` substring used
/// for a Frida `Java.use(...).method.overload(<params>)` argument. Returns
/// an empty string for `()V`-shape or any input that lacks the outer parens.
pub fn proto_to_overload(proto: &str) -> &str {
    let Some(rest) = proto.strip_prefix('(') else {
        return "";
    };
    let Some(end) = rest.find(')') else {
        return "";
    };
    // `find(')')` returns a byte index at a `)` ASCII byte — always a
    // UTF-8 boundary — so `get(..end)` cannot straddle a multibyte char.
    rest.get(..end).unwrap_or("")
}

/// Split a smali parameter substring (the output of [`proto_to_overload`])
/// into individual field-type slices. `Ljava/lang/String;I[B` →
/// `["Ljava/lang/String;", "I", "[B"]`. Array prefixes `[` chain to the
/// element type; `Lpkg/Type;` reference forms run to the next `;`; every
/// other byte is a single-letter primitive (`I` `J` `D` `B` `C` `S` `F` `Z`).
/// Adversarial input (unterminated `L...`, trailing `[`) yields whatever
/// partial slice was accumulated; the caller treats malformed protos as a
/// codegen-level signal, not a panic.
pub fn smali_split_args(overload_sig: &str) -> Vec<&str> {
    let bytes = overload_sig.as_bytes();
    let mut out: Vec<&str> = Vec::new();
    let mut i: usize = 0;
    while let Some(&b) = bytes.get(i) {
        let start = i;
        while let Some(&c) = bytes.get(i) {
            if c == b'[' {
                i = i.saturating_add(1);
                continue;
            }
            break;
        }
        match bytes.get(i).copied() {
            Some(b'L') => {
                i = i.saturating_add(1);
                while let Some(&c) = bytes.get(i) {
                    i = i.saturating_add(1);
                    if c == b';' {
                        break;
                    }
                }
            }
            Some(_) => {
                i = i.saturating_add(1);
            }
            None => break,
        }
        if let Some(slice) = overload_sig.get(start..i) {
            out.push(slice);
        }
        let _ = b; // silence unused-binding warning; the byte itself is observed via .get(i)
    }
    out
}

/// Convert a single smali field type into the Java type name string that
/// frida-java-bridge's `Method.overload(...)` resolver expects.
///
/// Primitives map to lowercase Java keywords (`I` → `int`, `J` → `long`,
/// etc.). Reference types `Lpkg/foo/Bar;` lose the surrounding `L`/`;`
/// and have `/` replaced with `.` (`Ljava/lang/String;` →
/// `java.lang.String`). Array types keep the leading `[`-chain and convert
/// only the inner element when it's a reference (`[I` → `[I`,
/// `[Ljava/lang/String;` → `[Ljava.lang.String;`) — Frida's array-type
/// resolver uses the JNI descriptor form for arrays, not a dotted suffix.
pub fn smali_field_to_dotted(field: &str) -> String {
    let mut depth: usize = 0;
    let mut rest = field;
    while let Some(stripped) = rest.strip_prefix('[') {
        depth = depth.saturating_add(1);
        rest = stripped;
    }
    let prefix: String = "[".repeat(depth);
    let body = match rest {
        "B" => "byte".to_string(),
        "C" => "char".to_string(),
        "D" => "double".to_string(),
        "F" => "float".to_string(),
        "I" => "int".to_string(),
        "J" => "long".to_string(),
        "S" => "short".to_string(),
        "V" => "void".to_string(),
        "Z" => "boolean".to_string(),
        ref_field if ref_field.starts_with('L') && ref_field.ends_with(';') => {
            let inner = ref_field
                .strip_prefix('L')
                .and_then(|s| s.strip_suffix(';'))
                .unwrap_or("");
            if inner.is_empty() {
                // Malformed `L;` (or `[L;`, etc.) — empty class name.
                // Emit the original field verbatim rather than dropping
                // to "". Empty output on non-empty input would silently
                // erase the param slot in the generated Frida JS
                // overload resolver call; preserving the malformed token
                // surfaces the bad input downstream (Frida's resolver
                // rejects `L;` cleanly) without losing position. Anchor
                // regression: fuzz target covers L-semi-empty-class case.
                ref_field.to_string()
            } else {
                let dotted = inner.replace('/', ".");
                if depth == 0 {
                    dotted
                } else {
                    format!("L{dotted};")
                }
            }
        }
        other => other.to_string(),
    };
    if depth > 0 {
        match rest {
            "B" | "C" | "D" | "F" | "I" | "J" | "S" | "V" | "Z" => format!("{prefix}{rest}"),
            _ => format!("{prefix}{body}"),
        }
    } else {
        body
    }
}

/// Escape a string for safe embedding inside a JS single-quoted literal.
fn js_escape(s: &str) -> String {
    s.replace('\\', "\\\\")
        .replace('\'', "\\'")
        .replace('\n', "\\n")
        .replace('\r', "\\r")
}

/// Render the per-method body of one `Java.perform(function() { ... })`
/// block. The `flags` slot is `Some(u32)` when `parse_class_data`
/// resolved the method's `access_flags`; `None` means xrefs found the
/// method via the string pool but `parse_class_data` returned `Err` for
/// the owning class (truncated/adversarial DEX). The unknown case emits
/// a loud comment + skips the overload binding, since the same method
/// could be NATIVE — binding an overload to a native body throws inside
/// Frida at runtime.
fn emit_class_body(class_methods: &[(&MethodKey, Option<u32>)]) -> String {
    let mut body = String::new();
    for (mk, flags) in class_methods {
        let flags = match flags {
            Some(f) => *f,
            None => {
                body.push_str(&format!(
                    "  // method {name}: access_flags unknown (parse_class_data failed for owning class); hook manually after verifying it is concrete (not native/abstract).\n",
                    name = mk.name,
                ));
                continue;
            }
        };
        if flags & ACCESS_FLAG_NATIVE != 0 {
            body.push_str(&format!(
                "  // native method {name}: hook via Interceptor.attach at the JNI symbol (Java.use overload is unreliable on native bodies).\n",
                name = mk.name,
            ));
            continue;
        }
        if flags & ACCESS_FLAG_ABSTRACT != 0 {
            body.push_str(&format!(
                "  // abstract method {name}: no body to intercept (hook a concrete subclass override instead).\n",
                name = mk.name,
            ));
            continue;
        }
        let sig = proto_to_overload(&mk.proto);
        let dotted_args: Vec<String> = smali_split_args(sig)
            .into_iter()
            .map(smali_field_to_dotted)
            .collect();
        let overload_args = dotted_args
            .iter()
            .map(|a| format!("'{a}'"))
            .collect::<Vec<_>>()
            .join(", ");
        let arg_names: Vec<String> = (0..dotted_args.len()).map(|i| format!("arg{i}")).collect();
        let formals = arg_names.join(", ");
        let logged = if arg_names.is_empty() {
            String::new()
        } else {
            arg_names
                .iter()
                .map(|a| format!("' + {a}"))
                .collect::<Vec<_>>()
                .join(" + ', '")
                + " + '"
        };
        let log_call = if arg_names.is_empty() {
            format!("'{name}()'", name = mk.name)
        } else {
            format!("'{name}({logged})'", name = mk.name)
        };
        body.push_str(&format!(
            "  cls.{name}.overload({overload_args}).implementation = function({formals}) {{\n    \
             console.log({log_call});\n    \
             var ret = this.{name}.overload({overload_args}).apply(this, arguments);\n    \
             console.log('-> ' + ret);\n    \
             return ret;\n  \
             }};\n",
            name = mk.name,
        ));
    }
    body
}

/// Build the per-DEX `MethodKey → access_flags` map the Frida hook
/// codegen consults to choose an interception strategy. Walks each
/// non-shadowed `class_def`'s class_data, mapping every method to its
/// access_flags.
///
/// The map is keyed by `MethodKey`, so a same-key insert OVERWRITES. On
/// a duplicate-`class_idx` pair the canonical and shadow rows resolve to
/// the same `MethodKey` for a shared method_idx; without the shadow gate
/// the shadow row's access_flags would last-win over the canonical
/// row's, letting an attacker mask malicious flags in the emitted hook
/// stubs. The gate skips shadowed rows so the flags reflect the
/// first-wins canonical class that `class_def_for_type` pins.
fn build_method_flags(dex: &DexFile, raw: &[u8]) -> BTreeMap<MethodKey, u32> {
    let mut method_flags: BTreeMap<MethodKey, u32> = BTreeMap::new();
    for (class_defs_idx, cd) in dex.class_defs.iter().enumerate() {
        if dex.class_def_is_shadowed(class_defs_idx) {
            continue;
        }
        if cd.class_data_off == 0 {
            continue;
        }
        let class_data = match parse_class_data(raw, cd.class_data_off) {
            Ok(c) => c,
            Err(_) => continue,
        };
        for em in class_data
            .direct_methods
            .iter()
            .chain(class_data.virtual_methods.iter())
        {
            if let Some(mk) = method_key_for_idx(dex, em.method_idx) {
                method_flags.insert(mk, em.access_flags);
            }
        }
    }
    method_flags
}

/// Fuzz-only driver for [`build_method_flags`]. Calls it and drops the
/// result. The return type is `()` rather than `BTreeMap<MethodKey, u32>`
/// because `MethodKey` is private to this module; returning the map would
/// require making `MethodKey` `pub`, which the `private_interfaces` lint
/// forbids and which would widen the public surface for no consumer.
///
/// The drop preserves the no-panic property under test: the fuzz harness
/// reaches the full class_data walk in `build_method_flags`; the map's
/// contents are not the surface under test.
#[doc(hidden)]
pub fn __fuzz_build_method_flags(dex: &DexFile, raw: &[u8]) {
    let _ = build_method_flags(dex, raw);
}

#[allow(
    clippy::arithmetic_side_effects,
    clippy::as_conversions,
    reason = "`i + 1` is usize+1 bounded by ctx.dex.len() ≤ isize::MAX."
)]
pub fn frida(ctx: &CrossLayerContext, search: &str) -> anyhow::Result<Value> {
    // RAII drain guard: scanner walks hbc.function_get() which can emit
    // hermes findings on adversarial input. See xrefs.rs for rationale.
    let _drain_guard = crate::context::HermesFindingDrainGuard::install_discard();

    let re = regex::Regex::new(search)?;
    let mut hooks: Vec<FridaHook> = Vec::new();

    if let Some(hbc_owned) = ctx.hbc.as_ref() {
        let hbc = hbc_owned.hbc();
        let hbc_data = hbc_owned.bytes();
        let scan = droidsaw_hermes::scanner::scan_parsed(hbc, hbc_data);

        for i in 0..hbc.string_count {
            let value = hbc.string_as_str_or_empty(i);
            if !re.is_match(&value) {
                continue;
            }
            if let Some(func_ids) = scan.string_refs.get(&i) {
                for &fid in func_ids {
                    if fid >= hbc.function_count {
                        continue;
                    }
                    let f = hbc.function_get(fid);
                    let fname = if f.name_id < hbc.string_count {
                        hbc.string_as_str_or_empty(f.name_id).into_owned()
                    } else {
                        format!("anon_{fid}")
                    };
                    let safe = js_escape(&value);
                    let hook = format!(
                        "// Hermes function {fname} (id {fid}) references '{safe}'\n\
                         // Offset 0x{offset:x} is HBC-blob-relative (start of the function's bytecode body inside the parsed HBC blob); it is NOT a libhermes.so address. Do not pass it to Module.findBaseAddress('libhermes.so').add(...).\n\
                         // Disassemble: feed the HBC blob to hermes-dec.\n\
                         // Live interception (RN < 0.74 / Old Architecture): hook the React Native bridge JVM layer via Java.use('com.facebook.react.bridge.CatalystInstanceImpl').callFunction.overload(...).\n\
                         // Live interception (RN >= 0.74 Bridgeless / New Architecture): CatalystInstanceImpl is not on the call path; JS<->native goes through JSI. Hook a TurboModule's JNI entry point or invokeFunction on the JSI runtime (no stable single Java symbol).",
                        offset = f.offset,
                    );
                    hooks.push(FridaHook {
                        layer: "hbc".to_string(),
                        function: fname,
                        function_id: fid,
                        matched_string: value.clone().into_owned(),
                        hook,
                    });
                }
            }
        }
    }

    for (i, dex) in ctx.dex.iter().enumerate() {
        let label = format!("dex{}", i + 1);
        let Some(raw) = ctx.dex_bytes(i) else {
            continue;
        };
        let xrefs = match Xrefs::build(dex, raw) {
            Ok(x) => x,
            Err(_) => continue,
        };

        let method_flags = build_method_flags(dex, raw);

        for (matched_str, method_keys) in &xrefs.string_to_methods {
            if !re.is_match(matched_str) {
                continue;
            }
            let mut by_class: BTreeMap<&str, Vec<&MethodKey>> = BTreeMap::new();
            for mk in method_keys {
                by_class.entry(mk.class.as_ref()).or_default().push(mk);
            }
            for (class_desc, class_methods) in by_class {
                let java_class = class_desc
                    .trim_start_matches('L')
                    .trim_end_matches(';')
                    .replace('/', ".");
                let mut emit_inputs: Vec<(&MethodKey, Option<u32>)> =
                    Vec::with_capacity(class_methods.len());
                for mk in &class_methods {
                    emit_inputs.push((mk, method_flags.get(*mk).copied()));
                }
                let body = emit_class_body(&emit_inputs);
                let safe_match = js_escape(matched_str);
                let hook = format!(
                    "// matched string: '{safe_match}'\n\
                     Java.perform(function() {{\n  \
                     var cls = Java.use({java_class:?});\n\
                     {body}\
                     }});",
                );
                hooks.push(FridaHook {
                    layer: label.clone(),
                    function: java_class,
                    function_id: 0,
                    matched_string: matched_str.clone(),
                    hook,
                });
            }
        }
    }

    let count = hooks.len();
    let out = json!({
        "hooks": hooks,
        "_meta": meta(
            count,
            false,
            "DEX hook lines are runnable Frida JS (paste into a file and run `frida -U -f <package> -l hooks.js`); Hermes entries are reference comments for hermes-aware tools (offsets are HBC-blob-relative, not libhermes.so-relative).",
            &["xrefs", "strings", "decompile"],
        ),
    });
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn proto_to_overload_normal_two_args() {
        assert_eq!(proto_to_overload("(Ljava/lang/String;I)V"), "Ljava/lang/String;I");
    }

    #[test]
    fn proto_to_overload_empty_params() {
        assert_eq!(proto_to_overload("()V"), "");
    }

    #[test]
    fn proto_to_overload_two_primitives() {
        assert_eq!(proto_to_overload("(II)Z"), "II");
    }

    #[test]
    fn proto_to_overload_two_references() {
        assert_eq!(
            proto_to_overload("(Ljava/util/List;Ljava/lang/Object;)V"),
            "Ljava/util/List;Ljava/lang/Object;"
        );
    }

    #[test]
    fn proto_to_overload_array_param() {
        assert_eq!(proto_to_overload("([B)V"), "[B");
        assert_eq!(
            proto_to_overload("([Ljava/lang/String;)V"),
            "[Ljava/lang/String;"
        );
    }

    #[test]
    fn proto_to_overload_malformed_no_open_paren() {
        assert_eq!(proto_to_overload("V"), "");
    }

    #[test]
    fn proto_to_overload_malformed_no_close_paren() {
        assert_eq!(proto_to_overload("(Ljava/lang/String;"), "");
    }

    #[test]
    fn smali_split_args_zero() {
        assert!(smali_split_args("").is_empty());
    }

    #[test]
    fn smali_split_args_two_primitives() {
        assert_eq!(smali_split_args("II"), vec!["I", "I"]);
    }

    #[test]
    fn smali_split_args_mixed_reference_and_primitive() {
        assert_eq!(
            smali_split_args("Ljava/lang/String;I"),
            vec!["Ljava/lang/String;", "I"]
        );
    }

    #[test]
    fn smali_split_args_two_references() {
        assert_eq!(
            smali_split_args("Ljava/util/List;Ljava/lang/Object;"),
            vec!["Ljava/util/List;", "Ljava/lang/Object;"]
        );
    }

    #[test]
    fn smali_split_args_array_of_reference() {
        assert_eq!(smali_split_args("[Ljava/lang/String;"), vec!["[Ljava/lang/String;"]);
    }

    #[test]
    fn smali_split_args_array_of_primitive() {
        assert_eq!(smali_split_args("[B"), vec!["[B"]);
        assert_eq!(smali_split_args("[[I"), vec!["[[I"]);
    }

    #[test]
    fn smali_split_args_long_and_double_are_one_each() {
        assert_eq!(smali_split_args("JD"), vec!["J", "D"]);
    }

    #[test]
    fn smali_split_args_complex_combination() {
        assert_eq!(
            smali_split_args("Ljava/lang/String;[ILjava/lang/Object;J"),
            vec!["Ljava/lang/String;", "[I", "Ljava/lang/Object;", "J"]
        );
    }

    #[test]
    fn smali_field_to_dotted_primitives() {
        assert_eq!(smali_field_to_dotted("B"), "byte");
        assert_eq!(smali_field_to_dotted("C"), "char");
        assert_eq!(smali_field_to_dotted("D"), "double");
        assert_eq!(smali_field_to_dotted("F"), "float");
        assert_eq!(smali_field_to_dotted("I"), "int");
        assert_eq!(smali_field_to_dotted("J"), "long");
        assert_eq!(smali_field_to_dotted("S"), "short");
        assert_eq!(smali_field_to_dotted("V"), "void");
        assert_eq!(smali_field_to_dotted("Z"), "boolean");
    }

    #[test]
    fn smali_field_to_dotted_reference() {
        assert_eq!(
            smali_field_to_dotted("Ljava/lang/String;"),
            "java.lang.String"
        );
        assert_eq!(
            smali_field_to_dotted("Lcom/foo/bar/Baz;"),
            "com.foo.bar.Baz"
        );
    }

    #[test]
    fn smali_field_to_dotted_array_of_primitive_keeps_jni_form() {
        assert_eq!(smali_field_to_dotted("[B"), "[B");
        assert_eq!(smali_field_to_dotted("[I"), "[I");
        assert_eq!(smali_field_to_dotted("[[I"), "[[I");
    }

    #[test]
    fn smali_field_to_dotted_array_of_reference_uses_dotted_inner() {
        assert_eq!(
            smali_field_to_dotted("[Ljava/lang/String;"),
            "[Ljava.lang.String;"
        );
        assert_eq!(
            smali_field_to_dotted("[[Ljava/lang/Object;"),
            "[[Ljava.lang.Object;"
        );
    }

    #[test]
    fn smali_field_to_dotted_empty_class_name_preserves_verbatim() {
        // Anchor regression: `smali_field_to_dotted("L;") == ""` — empty
        // output on non-empty input silently drops a parameter slot in
        // the generated Frida JS overload resolver call. With the guard:
        // malformed L; tokens round-trip verbatim so position is
        // preserved; Frida's resolver rejects the bad type cleanly
        // downstream.
        assert_eq!(smali_field_to_dotted("L;"), "L;");
        assert_eq!(smali_field_to_dotted("[L;"), "[L;");
        assert_eq!(smali_field_to_dotted("[[L;"), "[[L;");
    }

    #[test]
    fn js_escape_handles_quotes_and_backslashes() {
        assert_eq!(js_escape("a'b"), "a\\'b");
        assert_eq!(js_escape("a\\b"), "a\\\\b");
        assert_eq!(js_escape("a\nb"), "a\\nb");
    }

    fn mk(class: &str, name: &str, proto: &str) -> MethodKey {
        MethodKey {
            class: class.into(),
            name: name.into(),
            proto: proto.into(),
        }
    }

    #[test]
    fn emit_class_body_native_method_emits_comment_only() {
        let m = mk("LFoo;", "doNative", "(I)V");
        let body = emit_class_body(&[(&m, Some(ACCESS_FLAG_NATIVE))]);
        assert!(
            body.contains("// native method doNative:"),
            "native branch must emit the named comment; got:\n{body}"
        );
        assert!(
            body.contains("Interceptor.attach"),
            "native comment must reference Interceptor.attach JNI route; got:\n{body}"
        );
        assert!(
            !body.contains(".overload("),
            "native branch must NOT emit overload codegen; got:\n{body}"
        );
        assert!(
            !body.contains(".implementation"),
            "native branch must NOT emit implementation codegen; got:\n{body}"
        );
    }

    #[test]
    fn emit_class_body_abstract_method_emits_comment_only() {
        let m = mk("LFoo;", "doAbstract", "()V");
        let body = emit_class_body(&[(&m, Some(ACCESS_FLAG_ABSTRACT))]);
        assert!(
            body.contains("// abstract method doAbstract:"),
            "abstract branch must emit the named comment; got:\n{body}"
        );
        assert!(
            body.contains("no body to intercept"),
            "abstract comment must call out the no-body reason; got:\n{body}"
        );
        assert!(
            !body.contains(".overload("),
            "abstract branch must NOT emit overload codegen; got:\n{body}"
        );
        assert!(
            !body.contains(".implementation"),
            "abstract branch must NOT emit implementation codegen; got:\n{body}"
        );
    }

    #[test]
    fn emit_class_body_unknown_flags_emits_loud_comment_and_skips_binding() {
        let m = mk("LFoo;", "mystery", "(I)V");
        let body = emit_class_body(&[(&m, None)]);
        assert!(
            body.contains("// method mystery: access_flags unknown"),
            "unknown-flags branch must emit a loud comment; got:\n{body}"
        );
        assert!(
            !body.contains(".overload("),
            "unknown-flags branch must NOT bind overload (could be native and throw); got:\n{body}"
        );
        assert!(
            !body.contains(".implementation"),
            "unknown-flags branch must NOT emit implementation codegen; got:\n{body}"
        );
    }

    #[test]
    fn emit_class_body_concrete_method_emits_comma_separated_dotted_overload() {
        let m = mk("LFoo;", "doConcrete", "(Ljava/lang/String;I)V");
        let body = emit_class_body(&[(&m, Some(0))]);
        assert!(
            body.contains("cls.doConcrete.overload('java.lang.String', 'int').implementation = function(arg0, arg1)"),
            "concrete method must overload-bind with comma-separated dotted args; got:\n{body}"
        );
        assert!(
            body.contains("this.doConcrete.overload('java.lang.String', 'int').apply(this, arguments)"),
            "concrete method must re-dispatch via apply; got:\n{body}"
        );
        assert!(
            !body.contains(".overload('Ljava/lang/String;I')"),
            "concrete method must NOT use smali-concat form (frida-java-bridge rejects it); got:\n{body}"
        );
    }

    #[test]
    fn emit_class_body_zero_arg_method_emits_empty_overload_call() {
        let m = mk("LFoo;", "noArgs", "()V");
        let body = emit_class_body(&[(&m, Some(0))]);
        assert!(
            body.contains("cls.noArgs.overload().implementation = function()"),
            "zero-arg method must use bare overload() not overload(''); got:\n{body}"
        );
    }

    #[test]
    fn emit_class_body_array_param_uses_jni_form() {
        let m = mk("LFoo;", "withArray", "([B[Ljava/lang/String;)V");
        let body = emit_class_body(&[(&m, Some(0))]);
        assert!(
            body.contains("cls.withArray.overload('[B', '[Ljava.lang.String;').implementation"),
            "array params must use JNI bracket form with dotted inner; got:\n{body}"
        );
    }

    #[test]
    fn emit_class_body_mixed_class_keeps_per_method_routing() {
        let n = mk("LFoo;", "doNative", "()V");
        let a = mk("LFoo;", "doAbstract", "()V");
        let c = mk("LFoo;", "doConcrete", "()V");
        let body = emit_class_body(&[
            (&n, Some(ACCESS_FLAG_NATIVE)),
            (&a, Some(ACCESS_FLAG_ABSTRACT)),
            (&c, Some(0)),
        ]);
        assert!(body.contains("// native method doNative:"));
        assert!(body.contains("// abstract method doAbstract:"));
        assert!(body.contains("cls.doConcrete.overload().implementation = function()"));
    }

    /// `build_method_flags` keys on `MethodKey`. On a duplicate-`class_idx`
    /// pair whose two rows declare the SAME method (same method_idx →
    /// same `MethodKey`) with DIFFERENT access_flags, an ungated walk
    /// would last-win to the shadow row's flags. Here the canonical
    /// (first) row's method is concrete (flags 0) and the shadow row's
    /// is ACC_NATIVE; the gate must keep the canonical flags so the
    /// codegen does not mistake the method for native.
    #[test]
    fn build_method_flags_uses_canonical_row_flags() {
        const ACCESS_FLAG_NATIVE: u32 = 0x100;
        // Both rows reference method_idx 0 (→ same MethodKey "canonMethod");
        // canonical non-native, shadow native.
        let fx = crate::analysis::dup_class_fixture::with_native_method_rows(
            0, false, 0, true,
        );
        let flags = build_method_flags(&fx.dex, &fx.raw);

        assert_eq!(flags.len(), 1, "the duplicated method must map to one key");
        let (_key, value) = flags.iter().next().expect("one entry");
        assert_eq!(
            *value & ACCESS_FLAG_NATIVE,
            0,
            "shadow row's ACC_NATIVE flag must not last-win over the canonical row's flags"
        );
    }
}
