//! DEX file parser producing the in-memory DexFile representation.
#![allow(missing_docs, reason = "internal")]
use std::collections::BTreeMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};

use scroll::{Pread, LE};

use crate::annotation::AnnotationDirectoryItem;
use crate::error::{bound_count, safe_add, safe_mul, DexError, Result};
use crate::header::DexHeader;
use crate::ids::*;
use crate::mutf8;

mod content_equiv;
pub use content_equiv::ContentEquiv;

/// On-disk `class_def_item` stride in bytes, per DEX spec
/// (§"class_def_item"): 8 × uint = 32 bytes.
const CLASS_DEF_ITEM_SIZE: usize = 32;
/// On-disk `proto_id_item` stride: shorty_idx + return_type_idx +
/// parameters_off = 3 × uint = 12 bytes.
const PROTO_ID_ITEM_SIZE: usize = 12;
/// On-disk `field_id_item` stride: class_idx + type_idx + name_idx =
/// ushort + ushort + uint = 8 bytes.
const FIELD_ID_ITEM_SIZE: usize = 8;
/// On-disk `method_id_item` stride: class_idx + proto_idx + name_idx =
/// ushort + ushort + uint = 8 bytes.
const METHOD_ID_ITEM_SIZE: usize = 8;
/// On-disk `string_id_item` stride: string_data_off = uint = 4 bytes.
const STRING_ID_ITEM_SIZE: usize = 4;
/// On-disk `type_id_item` stride: descriptor_idx = uint = 4 bytes.
const TYPE_ID_ITEM_SIZE: usize = 4;
/// On-disk `type_item` stride inside a `type_list`: type_idx = ushort =
/// 2 bytes.
const TYPE_LIST_ITEM_SIZE: usize = 2;

/// Cap on `super_class_chain` iteration depth. Defends against
/// attacker-crafted cyclic super chains: real Java/Kotlin hierarchies
/// rarely exceed depth 20, so 256 leaves ~10× headroom while preventing
/// unbounded iteration. On overflow the iterator silently terminates;
/// recognizer gates that depend on chain shape (e.g. `sealed_root_lca`)
/// treat truncation as "no match" rather than a hard error.
const MAX_SUPER_CHAIN_DEPTH: usize = 256;

/// DEX annotation visibility byte for `RetentionPolicy.RUNTIME`. Per the
/// DEX spec `annotation_visibility.VISIBILITY_RUNTIME = 0x01`. The
/// `@kotlin.Metadata` annotation is declared with RUNTIME retention so
/// its dex visibility byte is `0x01`.
const VISIBILITY_RUNTIME: u8 = 0x01;

/// Type descriptor for `@kotlin.Metadata`. The Kotlin-source-origin gate
/// for `signatures/kotlinc19/` recognizers — Java hand-written
/// `instanceof` chains do NOT carry this annotation, so requiring it
/// closes the false-positive risk on the sealed-`when` recognizers.
const KOTLIN_METADATA_DESCRIPTOR: &str = "Lkotlin/Metadata;";

/// Type descriptor for `java.lang.Object`. Excluded from
/// `sealed_root_lca` results — every reference type shares it, so it
/// carries no recognizer signal as a "common ancestor".
const OBJECT_DESCRIPTOR: &str = "Ljava/lang/Object;";

/// Suffix that kotlinc-1.9 appends to the synthetic class generated for
/// a Kotlin source file containing top-level functions or properties.
/// E.g. `Foo.kt` with `fun bar() {}` produces class `LFooKt;`. The
/// suffix is the cheap-gate discriminator used by
/// [`DexFile::is_kotlin_facade_candidate`]; the full facade gate is
/// completed at emit time by inspecting parsed class_data structure.
const KOTLIN_FACADE_SUFFIX: &str = "Kt;";

/// Method names auto-generated by kotlinc-1.9 for every `data class`. The
/// presence of `component1` and `copy` virtual methods on a class with
/// `@kotlin.Metadata` is the structural signal that gates
/// [`DexFile::is_kotlin_data_class`]. Other auto-generated members
/// (`equals` / `hashCode` / `toString` / `copy$default` / `getX`) overlap
/// with non-data-class shapes (any class with overridden `equals` has
/// these), so the discriminator narrows to `component1` + `copy` which
/// have no source-level analogue outside the data-class lowering.
const KOTLIN_DATA_CLASS_COMPONENT1: &str = "component1";
const KOTLIN_DATA_CLASS_COPY: &str = "copy";

/// JVM-internal type descriptor of `kotlin.jvm.internal.DefaultConstructorMarker`.
/// kotlinc emits a synthetic constructor `<init>(<root>;DefaultConstructorMarker)V`
/// on every sealed-class root + every sealed subclass; the marker's role is
/// to discriminate the kotlinc-internal "null-check-skipping" variant from
/// the public ctor. Two-ctor pattern (private real ctor + DefaultConstructorMarker
/// variant) is the structural signal that
/// [`DexFile::is_kotlin_sealed_root`] uses alongside the sealed-class
/// abstract + final-private-ctor pattern.
const KOTLIN_DEFAULT_CONSTRUCTOR_MARKER_DESCRIPTOR: &str =
    "Lkotlin/jvm/internal/DefaultConstructorMarker;";

/// Field name of the singleton holder on every Kotlin `object`. kotlinc
/// emits `<Sub>.INSTANCE` for every `object Sub : SealedRoot()` declaration.
/// Same constant as the `signatures::kotlinc19::when_sealed_object`
/// recognizer's per-arm match probe; lifted to module-level here so the
/// sealed-object subclass predicate ([`DexFile::is_kotlin_sealed_object_subclass`])
/// can reuse it.
const KOTLIN_INSTANCE_FIELD_NAME: &str = "INSTANCE";

/// Constructor method name in DEX. Used by sealed-class predicates to
/// scan ctors for the kotlinc DefaultConstructorMarker pattern.
const INIT_METHOD_NAME: &str = "<init>";

/// Access flag bits used by sealed-class predicates. Mirrors `0x0400`
/// (ACC_ABSTRACT) + `0x0010` (ACC_FINAL) + `0x0002` (ACC_PRIVATE) + `0x0008`
/// (ACC_STATIC). Names spelled out for readability — the DEX spec calls
/// these out via the same hex values.
const ACC_PRIVATE: u32 = 0x0002;
const ACC_STATIC: u32 = 0x0008;
const ACC_FINAL: u32 = 0x0010;
const ACC_ABSTRACT: u32 = 0x0400;

/// Parsed DEX file with all ID pools resolved.
#[derive(Debug, PartialEq)]
pub struct DexFile {
    pub header: DexHeader,
    /// The DEX string pool. Each entry carries the on-disk MUTF-8
    /// bytes plus the parser's decode result plus two on-disk
    /// gauges (declared UTF-16 char count, NUL-terminator presence).
    /// See [`crate::DexString`] for the full shape and accessor
    /// discipline.
    ///
    /// This field replaces five parallel `Vec<_>`s previously held
    /// on `DexFile` (`strings: Vec<String>`, `string_raw_bytes:
    /// Vec<Vec<u8>>`, `lossy_decode_marks: Vec<bool>`,
    /// `declared_string_lengths: Vec<u32>`, `missing_terminator_marks:
    /// Vec<bool>`). The relationship between bytes and decode result
    /// is now structural — they live in the same variant of
    /// `DexString` and cannot desync.
    ///
    /// **Empirical anchor**: corpus measurement on 415 APKs / 972 DEXes
    /// found **11.32%** of production DEXes contain at least one
    /// [`crate::DexString::MalformedMutf8`] entry. The dominant
    /// real-world source is Kotlin `@Metadata` packing channels
    /// embedding binary data via unpaired UTF-16 code units.
    pub strings: Vec<crate::DexString>,
    /// Per-StringIdx on-disk `string_data_off` from the input's
    /// string_ids section. Parallel to `strings` (same length, same
    /// indexing). Used by `emit_string_pool` under
    /// `EmitConfig::preserve_data_section_layout` to lay out
    /// `string_data` in the input's physical-disk order — d8 and other
    /// toolchains may put strings in string_data at offsets that
    /// don't match StringIdx ascending order. Preserving that order
    /// is what closes the byte-identity gap on those inputs.
    /// Empty on hand-built IR (no parse).
    pub string_data_offs: Vec<u32>,
    pub type_descriptors: Vec<String>,
    pub protos: Vec<ProtoIdItem>,
    pub fields: Vec<FieldIdItem>,
    pub methods: Vec<MethodIdItem>,
    pub class_defs: Vec<ClassDefItem>,
    pub annotations: BTreeMap<u32, AnnotationDirectoryItem>,
    /// Type-lists keyed by original `parameters_off` / `interfaces_off`.
    pub type_lists: BTreeMap<u32, Vec<TypeIdx>>,
    /// Resolved per-class field/method inventory, keyed by original
    /// `class_data_off`. Populated eagerly during parse so emit can
    /// round-trip without re-reading the byte buffer.
    pub class_datas: BTreeMap<u32, crate::decode::ClassData>,
    /// Raw on-disk bytes for each parsed `class_data_item`, parallel to
    /// `class_datas` (same key set). Captured at parse time so emit
    /// can replay verbatim under
    /// `EmitConfig::preserve_data_section_layout` without normalizing
    /// non-minimal ULEB128 encodings of `field_idx_diff`,
    /// `access_flags`, or `code_off`. Preserves byte-level identity
    /// through ULEB128 redundant-encoding normalization. Same discipline
    /// as `debug_info_raw_bytes`.
    pub raw_class_data_bytes: BTreeMap<u32, Vec<u8>>,
    /// Resolved per-method code bodies (insns + tries + payloads),
    /// keyed by original `code_off`. Populated eagerly.
    pub code_items: BTreeMap<u32, crate::decode::CodeItem>,
    /// Decoded debug_info state-machine results keyed by original
    /// `debug_info_off`. Populated eagerly post-construction (needs
    /// strings + type_descriptors resolved). Tolerant to per-entry
    /// failure — entries whose state machine fails to decode are
    /// silently skipped (matches class_datas / code_items pattern).
    pub debug_infos: BTreeMap<u32, crate::debug::DebugInfo>,
    /// Raw on-disk bytes for each parsed debug_info_item, parallel to
    /// `debug_infos` (same key set modulo tolerant-skip). `emit_dex`
    /// writes these bytes verbatim into its own data section and
    /// rewrites each `code_item.debug_info_off` to point into the
    /// emitter-controlled output region. See
    /// `emit_dex::emit_debug_info_section` for the dangling-pointer
    /// argument that this preservation closes by construction.
    pub debug_info_raw_bytes: BTreeMap<u32, Vec<u8>>,
    /// Total tiling of the input's debug_info section as a sequence of
    /// typed regions. Constructed by [`walk_debug_info_section_full`]
    /// after the per-code-item pass populates `debug_info_raw_bytes`.
    ///
    /// Invariants (verified by [`section_layout_gauge`]):
    /// - Regions are sorted by `offset` ascending.
    /// - No two regions overlap.
    /// - The first region starts at the section's input offset; the last
    ///   region ends at the next map_entry's offset (or `file_size` if
    ///   the section is last).
    /// - Σ region.len == section span.
    /// - The number of `Entry` regions equals the map_list's `size` field
    ///   (which is COUNT, not bytes — DEX §3.2), UNLESS an
    ///   `UnparseableTail` is present.
    ///
    /// Empty when the input lacks a DEBUG_INFO_ITEM map entry.
    /// Used by `EmitConfig::preserve_data_section_layout` to refuse the
    /// preserve emit with a typed `PreserveModeViolation` if the input
    /// can't be faithfully round-tripped.
    pub debug_info_section_layout: Vec<SectionRegion>,
    /// Total tiling of the input's annotation_set section. Same shape +
    /// invariants as [`Self::debug_info_section_layout`]. Constructed
    /// by [`walk_annotation_set_section_full`] after the
    /// reference-traversal pass populates `annotation_sets`. Used by
    /// preserve-mode emit to verify total tiling.
    pub annotation_set_section_layout: Vec<SectionRegion>,
    /// `true` iff the input header's stored Adler-32 + SHA-1 both
    /// match what we compute over the input bytes
    /// (Adler-32 over `input[12..]`, SHA-1 over `input[32..]`).
    ///
    /// `false` indicates the input ships with non-canonical
    /// checksums — the bytes were produced by a tool that wrote a
    /// stale/wrong Adler or SHA. Default emit recomputes correct
    /// values; the resulting output is valid DEX but differs from
    /// source in `bytes[8..32]`. Surfaces in `EmitOutput.applied_transformations`
    /// as `CanonicalTransform::InputChecksumNormalized` so the
    /// 24-byte cascade is attributable rather than silent.
    ///
    /// Large-scale testing shows approximately 5.4% of DEX files trip this.
    pub input_checksums_canonical: bool,
    /// `annotation_set_item` contents, keyed by on-disk offset.
    /// Value is the list of `annotation_off` entries in the set.
    pub annotation_sets: BTreeMap<u32, Vec<u32>>,
    /// `annotation_set_ref_list` contents (for parameter
    /// annotations), keyed by on-disk offset. Value is the list of
    /// `annotation_set_off` entries in the ref list.
    pub annotation_set_ref_lists: BTreeMap<u32, Vec<u32>>,
    /// `annotation_item` leaves, keyed by on-disk offset. Carries
    /// visibility + encoded_annotation payload.
    pub annotation_items: BTreeMap<u32, crate::annotation::AnnotationItem>,
    /// Per-annotation_item deep pre-order widths covering every width-
    /// bearing primitive descendant inside the annotation's encoded
    /// body. Keyed by the same offset as `annotation_items`. Used by
    /// `EmitConfig::preserve_data_section_layout` to emit annotation
    /// bodies byte-identically — without this, nested Float / Double
    /// values get re-encoded to full width and break byte-identity even
    /// when the top-level encoded_array widths matched.
    pub annotation_item_widths: BTreeMap<u32, Vec<u8>>,
    /// Static-field initial values (encoded_array_item), keyed by
    /// the class_def's `static_values_off`.
    pub encoded_arrays: BTreeMap<u32, Vec<crate::annotation::EncodedValue>>,
    /// Per-encoded_array_item DEEP pre-order widths, covering every
    /// width-bearing primitive descendant — not just top-level values.
    /// Keyed by the same offset as `encoded_arrays`. Populated at parse
    /// time via [`crate::annotation::collect_encoded_array_widths_pre_order`]
    /// after the top-level parse. Consumed by emit under
    /// `EmitConfig::preserve_encoded_value_width = true` (via
    /// `emit_encoded_array_with_widths`) and by
    /// `derive_applied_transformations` for width-canonicalization
    /// attribution. Pre-order means: walk in the same order emit's
    /// iterative pop produces, so push/pop pair up.
    pub encoded_array_widths: BTreeMap<u32, Vec<u8>>,
    /// `method_handle_item` rows resolved from the map_list. Each row
    /// captures a `(kind, field_or_method_id)` pair used by
    /// `invoke-custom` bootstrap arguments (`LambdaMetafactory`) and
    /// `const-method-handle`. Empty when the map_list has no
    /// `TYPE_METHOD_HANDLE_ITEM` section (pre-O DEX).
    pub method_handles: Vec<MethodHandleItem>,
    /// Raw entries from the DEX `map_list` (header.map_off). Each entry
    /// is a `(type_code, size, offset)` triple. Populated eagerly at
    /// parse time. Empty when `header.map_off == 0` (rare; spec-permitted
    /// for files without a map list) or the map_list is malformed.
    ///
    /// **Used by**: `emit_dex_inner` for per-section emit-vs-input
    /// offset comparison (alignment-padding observation). The order of
    /// entries matches the input's on-disk map_list order, which may
    /// or may not be canonical (offset-sorted). Emit's offset-sort
    /// canonicalizes; per-section displacements surface via
    /// `CanonicalTransform::AlignmentPaddingInserted` at downstream
    /// alignment boundaries (the byte-count attributed to the
    /// reordering's downstream cascade, not to the reorder itself —
    /// map-list-reorder direct observation is a documented follow-up).
    pub map_entries: Vec<MapEntry>,
    /// Call-site encoded_arrays resolved from the map_list. Each index
    /// in this `Vec` matches an `invoke-custom` pool index; the
    /// encoded_array offset per call_site, one entry per
    /// `invoke-custom` pool index. The encoded_array content lives in
    /// [`Self::encoded_arrays`] keyed by this offset; multiple
    /// `call_site_ids` may share an offset (legal under DEX §3.10:
    /// distinct call_site indexes pointing at the same bootstrap
    /// encoded_array). Sharing IS preserved — the parser inserts each
    /// distinct offset's content into `encoded_arrays` exactly once.
    /// Empty when the map_list has no `TYPE_CALL_SITE_ID_ITEM`
    /// section.
    pub call_site_ids: Vec<u32>,
    /// Parse-time silent-skip record. Every subsection
    /// (annotation-directory / annotation-set / annotation-set-ref-list
    /// / annotation-item / encoded_array / class_data / code_item) that
    /// fails to parse gets an entry here instead of being silently
    /// dropped. Downstream callers that care about completeness (emit,
    /// differential testing, detection) check
    /// `dex.parse_errors.is_empty()`; default analysis paths ignore it.
    ///
    /// A tolerant parser that drops data without surfacing the drop
    /// is indistinguishable from a strict parser that accepts garbage;
    /// the attacker controls what gets dropped, so parse-emit-parse
    /// diverges from the source bytes and becomes an evasion primitive.
    pub parse_errors: Vec<ParseFailure>,
    /// O(1) lookup table from `TypeIdx` to an index in `class_defs`.
    /// Populated eagerly at the end of `parse_inner` from `class_defs`.
    /// `None` for type indices that have no corresponding `class_def`
    /// (external references, primitives, arrays, out-of-range).
    ///
    /// Code that mutates `class_defs` post-parse (test fixtures only)
    /// must call [`DexFile::rebuild_class_def_index`] to keep this
    /// consistent. Mismatched length triggers a fallback linear scan
    /// in the private `DexFile::class_def_for_type` lookup.
    ///
    /// Closes the per-call linear scan that recognizers
    /// (`class_has_kotlin_metadata`, `is_kotlin_data_class`,
    /// `is_kotlin_sealed_root`, etc.) hit from per-class loops; the
    /// scan was the #1 frame in flamegraphs of large APK audits.
    pub class_def_index: Vec<Option<u32>>,
}

/// One silent-skip record from the tolerant parser. `offset` gives the
/// byte locus; `kind` identifies which subsection failed. Deliberately
/// omits a `detail: String` message to avoid per-failure allocation on
/// adversarial corpora with many malformed subsections; `offset + kind`
/// is sufficient for diagnosis via re-parsing or hexdumping.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct ParseFailure {
    pub kind: ParseFailureKind,
    pub offset: u32,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum ParseFailureKind {
    AnnotationDirectory,
    AnnotationSet,
    AnnotationSetRefList,
    AnnotationItem,
    EncodedArray,
    ClassData,
    CodeItem,
    DebugInfo,
    /// `extend_type_lists_from_class_defs` returned `Err` for a
    /// `class_def`'s `interfaces_off` (offset past EOF, malformed type-list
    /// header, or OOB type_idx). Recovered by skipping the interface list
    /// for that class; emit refuses to round-trip the partial IR via the
    /// standard `parse_errors.is_empty()` gate.
    Interfaces,
    /// `read_map_list(data, header.map_off)` returned `Err` (arithmetic
    /// overflow, bounds violation, or scroll read failure). The parser
    /// recovers by treating `map_entries` as empty; the typed record here
    /// is the contract that prevents `diag::collect_header_map_findings`
    /// from emitting a misleading "map claims 0 but header claims N"
    /// disagreement when the truth is that the map is unreadable.
    MapList,
    /// Two `class_def_item` rows in this DEX share the same `class_idx`.
    /// Spec §3.4.1 implies uniqueness but does not mandate an explicit
    /// validator check; some test corpora and adversarial APKs ship this
    /// shape. Without the guard: `rebuild_class_def_index` silently
    /// overwrote the index slot per-iteration, so the O(1) `class_def_for_type`
    /// lookup resolved to the LAST row while linear-scan callers
    /// (`dex.class_defs.iter().filter(|cd| cd.class_idx == T)`) saw both
    /// — index-vs-iteration disagreement is an evasion primitive (an
    /// attacker plants a benign first row + a malicious second row
    /// sharing `class_idx`; detectors via `class_def_for_type` see only
    /// the malicious one while iteration-based detectors see both).
    /// With the guard: first-wins per Android's AOSP `DexFile::FindClassDef`
    /// behavior — the index slot pins to the FIRST encounter, duplicate
    /// rows record a `DuplicateClassDef` ParseFailure for diag surfacing.
    DuplicateClassDef,
}

/// A typed region within a data-section's input byte span. Section
/// walkers (currently: `walk_debug_info_section_full`) produce a
/// `Vec<SectionRegion>` covering every byte of the section in offset
/// order, so the emitter can faithfully reconstruct the section under
/// `preserve_data_section_layout` without inventing zeros for gaps.
///
/// Variants are mutually exclusive at any byte position:
/// - `Entry`: a well-formed item the parser captured (raw bytes
///   stored in the section's per-offset map, e.g.
///   `DexFile::debug_info_raw_bytes`).
/// - `Gap`: inter-entry padding the parser observed but doesn't
///   interpret (replayed verbatim by the emitter).
/// - `UnparseableTail`: a trailing range where scan failed; preserve
///   mode refuses on encountering one (the parser couldn't
///   re-interpret these bytes, so emit can't promise
///   byte-identity to a downstream Android reader that
///   might).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SectionRegion {
    Entry { offset: u32, len: u32 },
    Gap { offset: u32, len: u32 },
    UnparseableTail { offset: u32, len: u32, reason: ParseFailureKind },
}

impl SectionRegion {
    pub fn offset(&self) -> u32 {
        match self {
            SectionRegion::Entry { offset, .. }
            | SectionRegion::Gap { offset, .. }
            | SectionRegion::UnparseableTail { offset, .. } => *offset,
        }
    }
    pub fn len(&self) -> u32 {
        match self {
            SectionRegion::Entry { len, .. }
            | SectionRegion::Gap { len, .. }
            | SectionRegion::UnparseableTail { len, .. } => *len,
        }
    }
    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }
    pub fn end(&self) -> u32 {
        self.offset().saturating_add(self.len())
    }
    pub fn is_unparseable_tail(&self) -> bool {
        matches!(self, SectionRegion::UnparseableTail { .. })
    }
    pub fn is_entry(&self) -> bool {
        matches!(self, SectionRegion::Entry { .. })
    }
}

/// Failure modes from a section walker. Surfaces concrete invariant
/// violations that are load-bearing for preserve-mode round-trip honesty.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SectionWalkError {
    /// Cursor overflow during walk (u32 add saturated/overflowed).
    /// On a spec-compliant DEX this is unreachable; on adversarial
    /// input it surfaces an attempt to drive the walker past u32::MAX.
    CursorOverflow { section: &'static str, at: u32 },
    /// An entry's `[offset, offset+len)` extends past the section's
    /// end (as derived from the next map_entry's offset). The entry
    /// can't be inserted because doing so would silently overrun
    /// adjacent data.
    EntryCrossesSection {
        section: &'static str,
        entry_offset: u32,
        entry_len: u32,
        section_end: u32,
    },
    /// The per-code-item parser pass and the section walker disagree
    /// on an entry's byte length at the same offset. One of them is
    /// reading the bytes differently, which is the bug the walker
    /// exists to surface (PoC||GTFO "dedupe stride lies").
    EntryLengthDisagreement {
        section: &'static str,
        offset: u32,
        first_pass_len: u32,
        walker_len: u32,
    },
}

/// Verify the completeness gauge over a `Vec<SectionRegion>`. Returns
/// `Ok(())` iff the regions form a total tiling of `[section_start,
/// section_end)` with no overlaps or gaps.
///
/// Required by the preserve-mode contract — the emitter trusts that
/// the parser's section layout is a complete tiling. Without this
/// gauge, the walker is theatre.
pub fn section_layout_gauge(
    section: &'static str,
    section_start: u32,
    section_end: u32,
    regions: &[SectionRegion],
) -> Result<()> {
    if regions.is_empty() {
        if section_start == section_end {
            return Ok(());
        }
        return Err(DexError::SectionLayoutGauge {
            section,
            why: "section non-empty but no regions",
        });
    }
    // Sorted-by-offset + no-overlap + contiguous.
    let mut cur = section_start;
    for (i, r) in regions.iter().enumerate() {
        if r.offset() != cur {
            let _ = i;
            return Err(DexError::SectionLayoutGauge {
                section,
                why: "region offsets non-contiguous (gap or overlap)",
            });
        }
        cur = cur.checked_add(r.len()).ok_or(DexError::SectionLayoutGauge {
            section,
            why: "region end overflowed u32",
        })?;
    }
    if cur != section_end {
        return Err(DexError::SectionLayoutGauge {
            section,
            why: "final region does not end at section_end",
        });
    }
    Ok(())
}

/// Walk the input's debug_info section start-to-end, constructing a
/// complete tiling as `Vec<SectionRegion>`. Returns an empty Vec if
/// no DEBUG_INFO_ITEM entry exists in the map_list.
///
/// Discipline:
/// - cursor uses `checked_add`; overflow is `SectionWalkError`.
/// - on `scan_debug_info_bytes` Ok: bound-check `pos + len <=
///   section_end` BEFORE insertion; record an `Entry` region.
/// - on `scan_debug_info_bytes` Err: halt at `pos`, record the
///   remaining bytes as a single `UnparseableTail` region, exit.
/// - dedupe by `(offset, length)`; a stored entry with a different
///   length than the walker's scan is `EntryLengthDisagreement`.
/// - the gauge runs after the walk; gauge failure is surfaced via
///   `dex.parse_errors` so preserve emit can refuse.
fn walk_debug_info_section_full(
    data: &[u8],
    dex: &mut DexFile,
) -> Result<Vec<SectionRegion>> {
    let Some(dbg_entry) = dex
        .map_entries
        .iter()
        .find(|e| e.type_code == crate::emit_dex::map_type::DEBUG_INFO_ITEM)
        .copied()
    else {
        return Ok(Vec::new());
    };
    let mut sorted = dex.map_entries.clone();
    sorted.sort_by_key(|e| e.offset);
    let section_start = dbg_entry.offset;
    let section_end = sorted
        .iter()
        .find(|e| e.offset > section_start)
        .map(|e| e.offset)
        .unwrap_or(dex.header.file_size);
    if section_end < section_start {
        return Err(DexError::SectionLayoutGauge {
            section: "debug_info",
            why: "section_end < section_start (map_entries out of order)",
        });
    }

    let mut regions: Vec<SectionRegion> = Vec::new();
    let mut pos = section_start;
    let target_count = dbg_entry.size;
    let mut walked: u32 = 0;

    // Loop termination is "lockstep": exit cleanly only when both
    // cursors hit their bound (walked == target_count AND pos
    // reaches section_end). Reaching target_count first leaves a
    // trailing Gap region in [pos, section_end). Reaching section_end
    // first means the map_list count overstates the section content;
    // that's surfaced via the count-divergence log below.
    while pos < section_end && walked < target_count {
        // Per-code-item dedupe path: if we already have raw bytes for
        // this offset, accept them but verify length against a fresh
        // scan. Length disagreement = one of the two parsers is wrong
        // (PoC||GTFO PoC#2).
        if let Some(stored) = dex.debug_info_raw_bytes.get(&pos) {
            let stored_len = u32::try_from(stored.len()).map_err(|_int_err| {
                DexError::SectionLayoutGauge {
                    section: "debug_info",
                    why: "stored raw bytes length exceeds u32::MAX",
                }
            })?;
            // Verify by re-scanning. Cheap on a slice; dedupe-without-verify
            // is load-bearing.
            match crate::debug::scan_debug_info_bytes(data, pos) {
                Ok(raw) => {
                    let scan_len = u32::try_from(raw.len()).map_err(|_int_err| {
                        DexError::SectionLayoutGauge {
                            section: "debug_info",
                            why: "scan raw bytes length exceeds u32::MAX",
                        }
                    })?;
                    if scan_len != stored_len {
                        if std::env::var("DROIDSAW_DEBUG_SECTION_WALK").ok().as_deref() == Some("1") {
                            eprintln!(
                                "[DROIDSAW_DEBUG_SECTION_WALK] length disagreement at pos={pos}: stored={stored_len} scan={scan_len} section_end={section_end} walked={walked}"
                            );
                        }
                        let tail_len = section_end.saturating_sub(pos);
                        regions.push(SectionRegion::UnparseableTail {
                            offset: pos,
                            len: tail_len,
                            reason: ParseFailureKind::DebugInfo,
                        });
                        return Ok(regions);
                    }
                }
                Err(e) => {
                    if std::env::var("DROIDSAW_DEBUG_SECTION_WALK").ok().as_deref() == Some("1") {
                        eprintln!(
                            "[DROIDSAW_DEBUG_SECTION_WALK] re-scan failed at stored pos={pos}: err={e:?}"
                        );
                    }
                    let tail_len = section_end.saturating_sub(pos);
                    regions.push(SectionRegion::UnparseableTail {
                        offset: pos,
                        len: tail_len,
                        reason: ParseFailureKind::DebugInfo,
                    });
                    return Ok(regions);
                }
            }
            let next = pos.checked_add(stored_len).ok_or(DexError::SectionLayoutGauge {
                section: "debug_info",
                why: "cursor overflow on dedupe path",
            })?;
            if next > section_end {
                return Err(DexError::SectionLayoutGauge {
                    section: "debug_info",
                    why: "stored entry crosses section_end",
                });
            }
            regions.push(SectionRegion::Entry {
                offset: pos,
                len: stored_len,
            });
            pos = next;
            walked = walked.saturating_add(1);
            continue;
        }

        match crate::debug::scan_debug_info_bytes(data, pos) {
            Ok(raw) => {
                let len = u32::try_from(raw.len()).map_err(|_int_err| {
                    DexError::SectionLayoutGauge {
                        section: "debug_info",
                        why: "scanned entry length exceeds u32::MAX",
                    }
                })?;
                if len == 0 {
                    // Zero-length entry is malformed; tail out.
                    let tail_len = section_end.saturating_sub(pos);
                    regions.push(SectionRegion::UnparseableTail {
                        offset: pos,
                        len: tail_len,
                        reason: ParseFailureKind::DebugInfo,
                    });
                    return Ok(regions);
                }
                let next = pos.checked_add(len).ok_or(DexError::SectionLayoutGauge {
                    section: "debug_info",
                    why: "cursor overflow during scan",
                })?;
                if next > section_end {
                    if std::env::var("DROIDSAW_DEBUG_SECTION_WALK").ok().as_deref() == Some("1") {
                        eprintln!(
                            "[DROIDSAW_DEBUG_SECTION_WALK] entry crosses section_end: pos={pos} entry_len={len} next={next} section_end={section_end} walked={walked} target_count={target_count}"
                        );
                    }
                    let tail_len = section_end.saturating_sub(pos);
                    regions.push(SectionRegion::UnparseableTail {
                        offset: pos,
                        len: tail_len,
                        reason: ParseFailureKind::DebugInfo,
                    });
                    return Ok(regions);
                }
                dex.debug_info_raw_bytes.insert(pos, raw);
                regions.push(SectionRegion::Entry { offset: pos, len });
                pos = next;
                walked = walked.saturating_add(1);
            }
            Err(e) => {
                if std::env::var("DROIDSAW_DEBUG_SECTION_WALK").ok().as_deref() == Some("1") {
                    eprintln!(
                        "[DROIDSAW_DEBUG_SECTION_WALK] debug_info scan FAILED at pos={pos} section_end={section_end} walked={walked} target_count={target_count} err={e:?}"
                    );
                }
                // Halt; no 1-byte resync. Record UnparseableTail so preserve
                // emit refuses; do NOT push parse_errors (would block default
                // emit too).
                let tail_len = section_end.saturating_sub(pos);
                if tail_len > 0 {
                    regions.push(SectionRegion::UnparseableTail {
                        offset: pos,
                        len: tail_len,
                        reason: ParseFailureKind::DebugInfo,
                    });
                }
                return Ok(regions);
            }
        }
    }

    // Lockstep finalization. Three exit cases:
    //
    // 1. walked == target_count AND pos == section_end: clean tile.
    // 2. walked == target_count AND pos <  section_end: trailing
    //    padding gap (legitimate; some toolchains pad the section to
    //    file/dword alignment). Record as Gap region. Tiling stays
    //    complete.
    // 3. walked <  target_count AND pos == section_end: map_list
    //    overstates the count (REcon PoC#1: "the count lies"). Surface
    //    as a debug-log finding; tiling stays complete.
    if walked == target_count && pos < section_end {
        let gap_len = section_end.saturating_sub(pos);
        regions.push(SectionRegion::Gap {
            offset: pos,
            len: gap_len,
        });
    }
    if walked != target_count
        && std::env::var("DROIDSAW_DEBUG_SECTION_WALK").ok().as_deref() == Some("1")
    {
        eprintln!(
            "[DROIDSAW_DEBUG_SECTION_WALK] debug_info: walked={walked} target_count={target_count} pos={pos} section_end={section_end} (map_list size disagrees with section content)"
        );
    }

    Ok(regions)
}

/// Walk the input's annotation_set section start-to-end, constructing
/// a complete tiling as `Vec<SectionRegion>`. Returns an empty Vec if
/// no ANNOTATION_SET_ITEM entry exists in the map_list.
///
/// Same discipline as [`walk_debug_info_section_full`]. Each annotation_set
/// item is `u32 size || size × u32`, so per-entry byte length is `4 + 4*size`.
/// The walker reuses [`crate::annotation::parse_annotation_set`] which returns
/// the parsed entries; byte length is derived from the parsed count.
///
/// Captures orphan entries unreachable through references from
/// `annotation_directory_item` / `annotation_set_ref_list`. Corpus measurement
/// found 84 files that were Tier 4 cascade-only because exactly one orphan
/// annotation_set was missing from the IR, causing the map_list count to be
/// off by 1 (e.g., callofduty/classes.dex input map_list count 10874 vs
/// IR 10873).
fn walk_annotation_set_section_full(
    data: &[u8],
    dex: &mut DexFile,
) -> Result<Vec<SectionRegion>> {
    let Some(set_entry) = dex
        .map_entries
        .iter()
        .find(|e| e.type_code == crate::emit_dex::map_type::ANNOTATION_SET_ITEM)
        .copied()
    else {
        return Ok(Vec::new());
    };
    let mut sorted = dex.map_entries.clone();
    sorted.sort_by_key(|e| e.offset);
    let section_start = set_entry.offset;
    let section_end = sorted
        .iter()
        .find(|e| e.offset > section_start)
        .map(|e| e.offset)
        .unwrap_or(dex.header.file_size);
    if section_end < section_start {
        return Err(DexError::SectionLayoutGauge {
            section: "annotation_set",
            why: "section_end < section_start",
        });
    }

    let mut regions: Vec<SectionRegion> = Vec::new();
    let mut pos = section_start;
    let target_count = set_entry.size;
    let mut walked: u32 = 0;

    while pos < section_end && walked < target_count {
        if let Some(stored) = dex.annotation_sets.get(&pos) {
            // Derived byte length: 4 (count u32) + 4 * entries.len().
            let stored_len_usize = 4usize.saturating_add(stored.len().saturating_mul(4));
            let stored_len = u32::try_from(stored_len_usize).map_err(|_int_err| {
                DexError::SectionLayoutGauge {
                    section: "annotation_set",
                    why: "stored entry byte length exceeds u32",
                }
            })?;
            // Verify by re-parsing — dedupe by (offset, length).
            match crate::annotation::parse_annotation_set(data, pos) {
                Ok(parsed) => {
                    let parsed_len_usize = 4usize.saturating_add(parsed.len().saturating_mul(4));
                    let parsed_len = u32::try_from(parsed_len_usize).unwrap_or(u32::MAX);
                    if parsed_len != stored_len {
                        let tail_len = section_end.saturating_sub(pos);
                        regions.push(SectionRegion::UnparseableTail {
                            offset: pos,
                            len: tail_len,
                            reason: ParseFailureKind::AnnotationSet,
                        });
                        return Ok(regions);
                    }
                }
                Err(_) => {
                    let tail_len = section_end.saturating_sub(pos);
                    regions.push(SectionRegion::UnparseableTail {
                        offset: pos,
                        len: tail_len,
                        reason: ParseFailureKind::AnnotationSet,
                    });
                    return Ok(regions);
                }
            }
            let next = pos.checked_add(stored_len).ok_or(DexError::SectionLayoutGauge {
                section: "annotation_set",
                why: "cursor overflow on dedupe path",
            })?;
            if next > section_end {
                return Err(DexError::SectionLayoutGauge {
                    section: "annotation_set",
                    why: "stored entry crosses section_end",
                });
            }
            regions.push(SectionRegion::Entry { offset: pos, len: stored_len });
            pos = next;
            walked = walked.saturating_add(1);
            continue;
        }

        // Orphan path: parse a fresh entry, store it.
        match crate::annotation::parse_annotation_set(data, pos) {
            Ok(parsed) => {
                let len_usize = 4usize.saturating_add(parsed.len().saturating_mul(4));
                let len = u32::try_from(len_usize).map_err(|_int_err| {
                    DexError::SectionLayoutGauge {
                        section: "annotation_set",
                        why: "parsed entry byte length exceeds u32",
                    }
                })?;
                if len == 0 {
                    let tail_len = section_end.saturating_sub(pos);
                    regions.push(SectionRegion::UnparseableTail {
                        offset: pos,
                        len: tail_len,
                        reason: ParseFailureKind::AnnotationSet,
                    });
                    return Ok(regions);
                }
                let next = pos.checked_add(len).ok_or(DexError::SectionLayoutGauge {
                    section: "annotation_set",
                    why: "cursor overflow during scan",
                })?;
                if next > section_end {
                    let tail_len = section_end.saturating_sub(pos);
                    regions.push(SectionRegion::UnparseableTail {
                        offset: pos,
                        len: tail_len,
                        reason: ParseFailureKind::AnnotationSet,
                    });
                    return Ok(regions);
                }
                dex.annotation_sets.insert(pos, parsed);
                regions.push(SectionRegion::Entry { offset: pos, len });
                pos = next;
                walked = walked.saturating_add(1);
            }
            Err(_) => {
                let tail_len = section_end.saturating_sub(pos);
                if tail_len > 0 {
                    regions.push(SectionRegion::UnparseableTail {
                        offset: pos,
                        len: tail_len,
                        reason: ParseFailureKind::AnnotationSet,
                    });
                }
                return Ok(regions);
            }
        }
    }

    // Lockstep finalization: trailing Gap if walker hit count first.
    if walked == target_count && pos < section_end {
        let gap_len = section_end.saturating_sub(pos);
        regions.push(SectionRegion::Gap { offset: pos, len: gap_len });
    }
    if walked != target_count
        && std::env::var("DROIDSAW_DEBUG_SECTION_WALK").ok().as_deref() == Some("1")
    {
        eprintln!(
            "[DROIDSAW_DEBUG_SECTION_WALK] annotation_set: walked={walked} target_count={target_count} pos={pos} section_end={section_end}"
        );
    }
    Ok(regions)
}

/// DEX method_handle_item (§ "method_handle_item"): eight bytes
/// on disk — `method_handle_type:u16`, `unused:u16`,
/// `field_or_method_id:u16`, `unused:u16`. Kind is interpreted per
/// DEX spec §"method_handle_type_codes".
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct MethodHandleItem {
    /// DEX method_handle_type_code. See `METHOD_HANDLE_TYPE_*`
    /// constants for meanings. 0x00..0x03 are field getter/setter
    /// kinds (static/instance × get/put). 0x04..0x08 are method
    /// invocation kinds (static/instance/constructor/direct/interface).
    pub kind: u16,
    /// For field kinds, this indexes into `fields`. For method kinds,
    /// it indexes into `methods`. Disambiguation is `kind`-based.
    pub field_or_method_id: u16,
}

/// Small, diff-friendly view of a parsed `DexFile` — written to the
/// `parser` stage of `droidsaw_common::diag` bundles when
/// `DROIDSAW_DUMP=parser` is set. Full `DexFile` serialization would
/// cascade Serialize across 17 types (ids, header, annotations incl. the
/// recursive `EncodedValue`); this snapshot carries the load-bearing
/// shape (version + pool counts) without that cost.
#[derive(serde::Serialize)]
struct ParserSnapshot<'a> {
    version: &'a str,
    file_size: u32,
    strings: usize,
    type_descriptors: usize,
    protos: usize,
    fields: usize,
    methods: usize,
    class_defs: usize,
    annotations: usize,
}

impl<'a> From<&'a DexFile> for ParserSnapshot<'a> {
    fn from(d: &'a DexFile) -> Self {
        Self {
            version: d.header.version(),
            file_size: d.header.file_size,
            strings: d.strings.len(),
            type_descriptors: d.type_descriptors.len(),
            protos: d.protos.len(),
            fields: d.fields.len(),
            methods: d.methods.len(),
            class_defs: d.class_defs.len(),
            annotations: d.annotations.len(),
        }
    }
}


#[allow(
    clippy::cast_possible_truncation,
    clippy::as_conversions,
    reason = "PROOF: two cast classes in this impl block. (1) Narrowing — every `pool.len() as u32` populates `DexError::IndexOob::pool_size`. Per DEX spec §III.0 every pool count is read from a u32 header field (`string_ids_size`, `type_ids_size`, `proto_ids_size`, `field_ids_size`, `method_ids_size`, `class_defs_size`) and bounded by `bound_count` before `Vec::with_capacity`; narrowing usize→u32 is exact for any spec-compliant input. (2) Widening — every `idx.0 as usize` / `header.xxx_off as usize` / `string_data_off as usize` / `pool_idx as usize` is u32→usize; lossless on all 32-bit-and-wider targets (workspace targets 64-bit only). Slice access is guarded by the `.get()` call that follows, so OOB is handled without UB."
)]
impl DexFile {
    /// Parse a DEX file from a byte slice, optionally charging an explicit
    /// resource budget.
    ///
    /// When `budget` is `Some`, charges `data.len()` bytes before parse begins,
    /// capping the file-read RSS contribution. Internal parse allocations (e.g.
    /// `Vec::with_capacity(count)` driven by header fields) are bounded by the
    /// existing `bound_count` / `CountExceeded` guards — both mechanisms are
    /// needed and complementary.
    ///
    /// Pass `None` to skip budget enforcement (test contexts, one-shot CLI
    /// commands, or any call site where unbounded parsing is acceptable). Pass
    /// `Some(&mut budget)` at trust boundaries (MCP `load`, server loops) to
    /// prevent adversarial inputs from consuming unbounded resources.
    ///
    /// Returns `Err(DexError::Budget(...))` when the budget is `Some` and
    /// the pre-parse charge is exhausted.
    pub fn parse(
        data: &[u8],
        budget: Option<&mut droidsaw_common::budget::Budget>,
    ) -> Result<Self> {
        if let Some(b) = budget {
            b.charge(data.len(), 0, "dex-parse-input")
                .map_err(DexError::Budget)?;
        }
        let mut h = DefaultHasher::new();
        data.hash(&mut h);
        let hash = format!("{:016x}", h.finish());
        droidsaw_common::diag::with_input_hash(&hash, || {
            let parsed = Self::parse_inner(data)?;
            droidsaw_common::diag::stage_dump("parser", &ParserSnapshot::from(&parsed));
            Ok(parsed)
        })
    }


    fn parse_inner(data: &[u8]) -> Result<Self> {
        let header = DexHeader::parse(data)?;
        header.verify_checksum(data)?;
        // SHA-1 is NOT validated by verify_checksum (which only enforces
        // Adler-32). Compute SHA-1 separately so we can attribute non-
        // canonical-input-SHA files via CanonicalTransform::InputChecksumNormalized
        // on the emit side. Real-world DEX files sometimes ship with
        // stored-SHA ≠ SHA-1(input[32..]).
        let input_checksums_canonical = {
            use sha1::{Digest, Sha1};
            let file_size = header.file_size as usize;
            data.get(32..file_size).is_some_and(|slice| {
                let mut sha = Sha1::new();
                sha.update(slice);
                let computed: [u8; 20] = sha.finalize().into();
                computed == header.signature
            })
        };

        let (strings, string_data_offs) = Self::parse_string_pool(data, &header)?;
        let type_descriptors = Self::parse_types(data, &header, &strings)?;
        let protos = Self::parse_protos(data, &header, &strings, type_descriptors.len())?;
        let type_lists = Self::parse_type_lists(data, &protos, type_descriptors.len())?;
        let fields = Self::parse_fields(data, &header, type_descriptors.len(), strings.len())?;
        let methods = Self::parse_methods(
            data,
            &header,
            type_descriptors.len(),
            protos.len(),
            strings.len(),
        )?;
        let mut type_lists = type_lists;
        let class_defs =
            Self::parse_class_defs(data, &header, type_descriptors.len(), strings.len())?;

        let mut annotations = BTreeMap::new();
        // Parse-error side-channel for silent-skip observability (see
        // `ParseFailure` above). Records locus + kind of every
        // tolerant-skip below, replacing `.ok()` / `let Ok(..) else
        // continue` drops with record-and-continue. Downstream
        // consumers opt in via `dex.parse_errors.is_empty()`.
        let mut parse_errors: Vec<ParseFailure> = Vec::new();

        // Type-lists are shared between protos (parameters_off) and
        // class_defs (interfaces_off) — both use the same on-disk
        // layout {u32 size; u16 idx[size]}. Extend the map with any
        // interface-list offsets the protos didn't already cover.
        // Per-class_def parse errors land in `parse_errors` as
        // `ParseFailureKind::Interfaces` records.
        Self::extend_type_lists_from_interfaces(
            data,
            &class_defs,
            type_descriptors.len(),
            &mut type_lists,
            &mut parse_errors,
        )?;
        let mut annotation_sets = BTreeMap::new();
        let mut annotation_set_ref_lists = BTreeMap::new();
        let mut annotation_items = BTreeMap::new();
        let mut annotation_item_widths: BTreeMap<u32, Vec<u8>> = BTreeMap::new();
        for cd in &class_defs {
            if cd.annotations_off == 0 {
                continue;
            }
            if let std::collections::btree_map::Entry::Vacant(entry) =
                annotations.entry(cd.annotations_off)
            {
                match crate::annotation::AnnotationDirectoryItem::parse(data, cd.annotations_off) {
                    Ok(dir) => {
                        entry.insert(dir);
                    }
                    Err(_) => parse_errors.push(ParseFailure {
                        kind: ParseFailureKind::AnnotationDirectory,
                        offset: cd.annotations_off,
                    }),
                }
            }
        }

        // Walk each AnnotationDirectoryItem's references and populate
        // the three sub-maps so emit can round-trip the full tree:
        //   directory → annotation_set / annotation_set_ref_list
        //   set / ref_list → annotation_item (leaf)
        // Malformed subsections are silently skipped (preserve
        // tolerant-parse discipline — emit will refuse to reference
        // unresolved entries).
        for dir in annotations.values() {
            let mut pending_sets: Vec<u32> = Vec::new();
            if dir.class_annotations_off != 0 {
                pending_sets.push(dir.class_annotations_off);
            }
            for f in &dir.fields {
                if f.annotations_off != 0 {
                    pending_sets.push(f.annotations_off);
                }
            }
            for m in &dir.methods {
                if m.annotations_off != 0 {
                    pending_sets.push(m.annotations_off);
                }
            }
            for p in &dir.parameters {
                if p.annotations_off == 0 {
                    continue;
                }
                // parameter.annotations_off → annotation_set_ref_list.
                // Reuses the same on-disk format as annotation_set
                // (u32 size + u32[size]). Each entry points to an
                // annotation_set_item which we then chase.
                if let std::collections::btree_map::Entry::Vacant(slot) =
                    annotation_set_ref_lists.entry(p.annotations_off)
                {
                    match crate::annotation::parse_annotation_set(data, p.annotations_off) {
                        Ok(refs) => {
                            slot.insert(refs.clone());
                            for set_off in refs {
                                if set_off != 0 {
                                    pending_sets.push(set_off);
                                }
                            }
                        }
                        Err(_) => parse_errors.push(ParseFailure {
                            kind: ParseFailureKind::AnnotationSetRefList,
                            offset: p.annotations_off,
                        }),
                    }
                }
            }
            for set_off in pending_sets {
                if annotation_sets.contains_key(&set_off) {
                    continue;
                }
                let entries = match crate::annotation::parse_annotation_set(data, set_off) {
                    Ok(e) => e,
                    Err(_) => {
                        parse_errors.push(ParseFailure {
                            kind: ParseFailureKind::AnnotationSet,
                            offset: set_off,
                        });
                        continue;
                    }
                };
                annotation_sets.insert(set_off, entries.clone());
                for item_off in entries {
                    if item_off == 0 || annotation_items.contains_key(&item_off) {
                        continue;
                    }
                    match crate::annotation::parse_annotation_item(data, item_off) {
                        Ok(item) => {
                            // annotation_item is `u8 visibility ||
                            // encoded_annotation body` — the body has no
                            // leading `0x1d` header byte. Walk the body
                            // starting after the visibility byte to recover
                            // deep widths for every nested primitive.
                            let body_start = item_off.saturating_add(1);
                            if let Ok(widths) = crate::annotation::collect_annotation_item_widths(
                                data,
                                body_start,
                                &item.annotation,
                            ) {
                                annotation_item_widths.insert(item_off, widths);
                            }
                            annotation_items.insert(item_off, item);
                        }
                        Err(_) => parse_errors.push(ParseFailure {
                            kind: ParseFailureKind::AnnotationItem,
                            offset: item_off,
                        }),
                    }
                }
            }
        }

        // Encoded arrays (static field initial values) keyed by the
        // class_def's static_values_off. Silent-skip on parse error
        // preserves tolerant-parse non-negotiable.
        let mut encoded_arrays = BTreeMap::new();
        let mut encoded_array_widths: BTreeMap<u32, Vec<u8>> = BTreeMap::new();
        for cd in &class_defs {
            if cd.static_values_off == 0 || encoded_arrays.contains_key(&cd.static_values_off) {
                continue;
            }
            match crate::annotation::parse_encoded_array(data, cd.static_values_off) {
                Ok((arr, _flat_widths)) => {
                    // Replace the flat (top-level-only) widths with deep
                    // pre-order widths covering every width-bearing
                    // primitive descendant — including nested values inside
                    // arrays-of-arrays / arrays-of-annotations. Needed for
                    // byte-identity preserve mode to recover d8's per-
                    // value trim choices for nested Float / Double, etc.
                    match crate::annotation::collect_encoded_array_widths_pre_order(
                        data,
                        cd.static_values_off,
                        &arr,
                    ) {
                        Ok(deep_widths) => {
                            encoded_array_widths.insert(cd.static_values_off, deep_widths);
                        }
                        Err(_) => {
                            // Width collection failed despite a successful
                            // parse — fall back silently: emit will use
                            // min-width and lose byte-identity on this
                            // encoded_array but parse-result survives.
                        }
                    }
                    encoded_arrays.insert(cd.static_values_off, arr);
                }
                Err(_) => parse_errors.push(ParseFailure {
                    kind: ParseFailureKind::EncodedArray,
                    offset: cd.static_values_off,
                }),
            }
        }

        // Eagerly resolve class_data + code_item subsections. Both are
        // keyed by their on-disk offset so emit can remap via a per-
        // section offset table. Malformed subsections (parser error)
        // are silently skipped here — the partial IR still supports
        // the analysis paths that don't require them.
        let mut class_datas = BTreeMap::new();
        let mut raw_class_data_bytes: BTreeMap<u32, Vec<u8>> = BTreeMap::new();
        let mut code_items = BTreeMap::new();
        for cd in &class_defs {
            if cd.class_data_off == 0 {
                continue;
            }
            let (class_data, consumed) =
                match crate::decode::parse_class_data_with_consumed(data, cd.class_data_off) {
                    Ok(pair) => pair,
                    Err(_) => {
                        parse_errors.push(ParseFailure {
                            kind: ParseFailureKind::ClassData,
                            offset: cd.class_data_off,
                        });
                        continue;
                    }
                };
            // Capture raw bytes for preserve-mode replay. Skip if
            // bounds-overflow (defensive; consumed is bounded by
            // parse success above so this branch is unreachable on
            // spec-compliant input).
            let start = cd.class_data_off as usize;
            let end = start.saturating_add(consumed);
            if let Some(slice) = data.get(start..end) {
                raw_class_data_bytes
                    .entry(cd.class_data_off)
                    .or_insert_with(|| slice.to_vec());
            }
            for em in class_data
                .direct_methods
                .iter()
                .chain(class_data.virtual_methods.iter())
            {
                if em.code_off == 0 || code_items.contains_key(&em.code_off) {
                    continue;
                }
                match crate::decode::parse_code_item(data, em.code_off) {
                    Ok(ci) => {
                        code_items.insert(em.code_off, ci);
                    }
                    Err(_) => parse_errors.push(ParseFailure {
                        kind: ParseFailureKind::CodeItem,
                        offset: em.code_off,
                    }),
                }
            }
            class_datas.insert(cd.class_data_off, class_data);
        }

        // Map-list-driven sections: method_handles (TYPE 0x0008) and
        // call_site_ids (TYPE 0x0007). Both are post-O additions; older
        // DEXes simply lack these sections so the vectors stay empty.
        // Malformed entries are silently skipped so a corrupt bootstrap
        // encoded_array doesn't abort the whole parse.
        let (method_handles, call_site_ids) = Self::parse_map_driven_sections(data, &header);

        // Populate dex.encoded_arrays with each call_site's referenced
        // encoded_array content. Keying by offset means sharing across
        // multiple call_site_ids (or sharing with class_def static_values)
        // is preserved automatically — BTreeMap dedupes by offset.
        // Without this, emit re-emits each call_site's content
        // separately and overruns the encoded_array_item section.
        // This deduping is necessary for correct section sizing.
        for &cs_off in &call_site_ids {
            if cs_off == 0 || encoded_arrays.contains_key(&cs_off) {
                continue;
            }
            match crate::annotation::parse_encoded_array(data, cs_off) {
                Ok((arr, _flat_widths)) => {
                    if let Ok(deep_widths) =
                        crate::annotation::collect_encoded_array_widths_pre_order(
                            data, cs_off, &arr,
                        )
                    {
                        encoded_array_widths.insert(cs_off, deep_widths);
                    }
                    encoded_arrays.insert(cs_off, arr);
                }
                Err(_) => {
                    parse_errors.push(ParseFailure {
                        kind: ParseFailureKind::EncodedArray,
                        offset: cs_off,
                    });
                }
            }
        }

        // Map entries are read again here for the public DexFile field;
        // `parse_map_driven_sections` reads them locally for its own
        // section walk but does not return them. Tolerant per
        // `read_map_list` contract: empty Vec on malformed map_list, with
        // a typed `ParseFailureKind::MapList` record so consumers can
        // distinguish "unreadable" from "legitimately empty" — the
        // header-vs-map cross-check (`diag::collect_header_map_findings`)
        // skips its disagreement check on this signal.
        let map_entries = match read_map_list(data, header.map_off) {
            Ok(v) => v,
            Err(_) => {
                parse_errors.push(ParseFailure {
                    kind: ParseFailureKind::MapList,
                    offset: header.map_off,
                });
                Vec::new()
            }
        };

        let mut dex = Self {
            header,
            strings,
            string_data_offs,
            type_descriptors,
            protos,
            fields,
            methods,
            class_defs,
            annotations,
            type_lists,
            class_datas,
            raw_class_data_bytes,
            code_items,
            annotation_sets,
            annotation_set_ref_lists,
            annotation_items,
            annotation_item_widths,
            encoded_arrays,
            encoded_array_widths,
            method_handles,
            call_site_ids,
            map_entries,
            debug_infos: BTreeMap::new(),
            debug_info_raw_bytes: BTreeMap::new(),
            debug_info_section_layout: Vec::new(),
            annotation_set_section_layout: Vec::new(),
            input_checksums_canonical,
            parse_errors,
            class_def_index: Vec::new(),
        };

        // Populate the type→class_def index. One pass over `class_defs`,
        // keyed by `TypeIdx`. Closes the per-recognizer linear scan that
        // dominated flamegraphs of multi-class APKs.
        dex.rebuild_class_def_index();

        // Eagerly populate debug_infos + debug_info_raw_bytes. Both
        // are keyed by the original on-disk `debug_info_off` for
        // remap during emit. The raw-bytes map is the byte-preservation
        // side-table that closes the dangling-pointer attack surface:
        // emit writes these bytes verbatim into an emitter-controlled
        // region of the output and rewrites each `code_item.debug_info_off`
        // into that region. See `emit_debug_info_section` for the
        // security argument.
        //
        // Tolerant per-entry: a malformed debug_info_item (truncated
        // stream, unknown opcode, ULEB decode failure) is silently
        // skipped rather than aborting the whole parse. Skipped
        // entries cause emit to fall back to `debug_info_off = 0` on
        // any code_item referencing the skipped offset (matching the
        // established zeroing behavior for the residual subset —
        // safe by construction).
        // Threaded with `registers_size` per code_item so the parser
        // can bound-check debug-info `register` operands per DEX spec
        // §3.4.6. When a single `debug_info_off` is referenced by
        // multiple code_items (deduped offsets), the first encountered
        // `registers_size` wins — see the `contains_key(&off)` dedupe
        // check below. That matches the prior offset-keyed behavior;
        // the bound check is still load-bearing on the dedupe winner.
        let debug_offs: Vec<(u32, u16)> = dex
            .code_items
            .values()
            .map(|ci| (ci.debug_info_off, ci.registers_size))
            .filter(|&(off, _)| off != 0)
            .collect();
        for (off, registers_size) in debug_offs {
            if dex.debug_info_raw_bytes.contains_key(&off) {
                continue;
            }
            let raw = match crate::debug::scan_debug_info_bytes(data, off) {
                Ok(r) => r,
                Err(_) => {
                    dex.parse_errors.push(ParseFailure {
                        kind: ParseFailureKind::DebugInfo,
                        offset: off,
                    });
                    continue;
                }
            };
            // Raw bytes captured; decoded form is best-effort — a
            // scan_debug_info_bytes that succeeds guarantees raw
            // bytes are well-formed per spec, but parse_debug_info's
            // reference-resolution may still fail on an IR with
            // out-of-range name_idx / type_idx. Populate both maps
            // independently — raw bytes are load-bearing for emit;
            // decoded is advisory. A decode failure is NOT added to
            // parse_errors here because the raw bytes DID succeed —
            // emit still round-trips cleanly.
            if let Ok(info) =
                crate::debug::parse_debug_info(data, off, registers_size, &dex)
            {
                dex.debug_infos.insert(off, info);
            }
            dex.debug_info_raw_bytes.insert(off, raw);
        }

        // Phase 2: full debug_info section walk.
        //
        // The per-code-item pass above only captures entries that some
        // parsed code_item references. Orphan entries (referenced by
        // silent-skipped code_items, or genuinely unreferenced by any
        // code_item) get missed, and preserve emit fills those bytes
        // with zeros — breaking byte-identity. This walker prevents
        // that silent data loss.
        //
        // The walker constructs a total tiling of the debug_info section
        // as a `Vec<SectionRegion>`: every byte in [section_start,
        // section_end) is covered by exactly one Entry / Gap /
        // UnparseableTail region. The completeness gauge runs after the
        // walk and asserts the tiling holds; preserve-mode emit checks
        // the gauge and refuses if the input can't be faithfully
        // round-tripped (typed `PreserveModeViolation`).
        //
        // Section-walk discipline:
        // - no 1-byte resync; halt on Err and record UnparseableTail
        // - checked_add on cursor; overflow is a hard failure
        // - bound-check entries before insertion
        // - dedupe by (offset, length); length mismatch is a hard
        //   failure (one of the two parsers is lying)
        dex.debug_info_section_layout = walk_debug_info_section_full(data, &mut dex)?;

        // Same discipline for annotation_set: capture orphan entries
        // unreachable through annotation_directory references. Without
        // this, the map_list count derived from `dex.annotation_sets`
        // is off-by-one on inputs that pack a referenced-by-nothing
        // annotation_set into the section (84 Tier 4 files with 1-byte
        // map_list diff cascading to Adler/SHA).
        dex.annotation_set_section_layout =
            walk_annotation_set_section_full(data, &mut dex)?;

        Ok(dex)
    }

    /// Parse `TYPE_METHOD_HANDLE_ITEM` (0x0008) and
    /// `TYPE_CALL_SITE_ID_ITEM` (0x0007) sections by walking the map
    /// list at `header.map_off`. Both sections are post-O additions;
    /// when absent, returns empty vectors. Silently skips malformed
    /// rows (the parser preserves the tolerant-parse non-negotiable).
    fn parse_map_driven_sections(
        data: &[u8],
        header: &DexHeader,
    ) -> (Vec<MethodHandleItem>, Vec<u32>) {
        const TYPE_CALL_SITE_ID_ITEM: u16 = 0x0007;
        const TYPE_METHOD_HANDLE_ITEM: u16 = 0x0008;
        const METHOD_HANDLE_ITEM_STRIDE: usize = 8;

        let Ok(map_entries) = read_map_list(data, header.map_off) else {
            return (Vec::new(), Vec::new());
        };

        let mut method_handles = Vec::new();
        // Store raw call_site offsets here; encoded_array content is
        // populated into dex.encoded_arrays (keyed by offset, dedup
        // automatic) by the caller. This preserves DEX §3.10 sharing
        // where two distinct call_site indexes point at the same
        // bootstrap encoded_array. Storing per-call_site copies would
        // discard the sharing and cause emit to overrun the section.
        let mut call_site_offsets: Vec<u32> = Vec::new();

        for entry in &map_entries {
            match entry.type_code {
                TYPE_METHOD_HANDLE_ITEM => {
                    let base = entry.offset as usize;
                    for i in 0..entry.size as usize {
                        let Some(row_off) = i
                            .checked_mul(METHOD_HANDLE_ITEM_STRIDE)
                            .and_then(|step| base.checked_add(step))
                        else {
                            break;
                        };
                        let Ok(kind) = data.pread_with::<u16>(row_off, LE) else {
                            break;
                        };
                        let Some(fmi_off) = row_off.checked_add(4) else {
                            break;
                        };
                        let Ok(field_or_method_id) =
                            data.pread_with::<u16>(fmi_off, LE)
                        else {
                            break;
                        };
                        method_handles.push(MethodHandleItem {
                            kind,
                            field_or_method_id,
                        });
                    }
                }
                TYPE_CALL_SITE_ID_ITEM => {
                    let base = entry.offset as usize;
                    for i in 0..entry.size as usize {
                        let Some(row_off) = i
                            .checked_mul(4)
                            .and_then(|step| base.checked_add(step))
                        else {
                            break;
                        };
                        let Ok(cs_off) = data.pread_with::<u32>(row_off, LE) else {
                            break;
                        };
                        call_site_offsets.push(cs_off);
                    }
                }
                _ => {}
            }
        }

        (method_handles, call_site_offsets)
    }

    /// Walk the `string_ids` section in a single pass and build the
    /// `Vec<DexString>` string pool. Each entry carries the on-disk
    /// MUTF-8 bytes plus the parser's decode result plus two
    /// per-entry gauges (declared UTF-16 char count, NUL-terminator
    /// presence) — see [`crate::DexString`].
    ///
    /// Replaces the previous two-walker arrangement (`parse_strings` +
    /// `parse_string_raw_bytes`) which read each string-pool entry
    /// twice (once to decode, once to capture raw bytes) with matching
    /// ULEB128 parses and NUL scans. The unified pass is the
    /// gauge-correct shape — there is no way for the bytes
    /// and the decode result to disagree by accident.
    fn parse_string_pool(
        data: &[u8],
        header: &DexHeader,
    ) -> Result<(Vec<crate::DexString>, Vec<u32>)> {
        let count = bound_count(
            header.string_ids_size,
            STRING_ID_ITEM_SIZE,
            data.len(),
            "string_ids",
        )?;
        let mut strings: Vec<crate::DexString> = Vec::with_capacity(count);
        let mut data_offs: Vec<u32> = Vec::with_capacity(count);
        let base = header.string_ids_off as usize;

        for i in 0..count {
            let off_pos = safe_add(
                base,
                safe_mul(i, STRING_ID_ITEM_SIZE, "parse_string_pool:i*stride")?,
                "parse_string_pool:off_pos",
            )?;
            let string_data_off: u32 =
                data.pread_with(off_pos, LE)
                    .map_err(|e| DexError::ScrollRead {
                        offset: off_pos,
                        source: e,
                    })?;
            data_offs.push(string_data_off);

            let sdo = string_data_off as usize;
            if sdo >= data.len() {
                return Err(DexError::OffsetOutOfBounds {
                    offset: string_data_off,
                    file_size: data.len(),
                });
            }

            let (declared_chars, uleb_len) = mutf8::read_uleb128(data, sdo)?;
            let str_start = safe_add(sdo, uleb_len, "parse_string_pool:str_start")?;

            let suffix = data.get(str_start..).unwrap_or(&[]);
            let nul_pos = suffix.iter().position(|&b| b == 0);
            let had_terminator = nul_pos.is_some();
            let str_end = match nul_pos {
                Some(p) => safe_add(str_start, p, "parse_string_pool:str_end")?,
                None => data.len(),
            };

            let raw = data.get(str_start..str_end).unwrap_or(&[]);
            let raw_bytes = raw.to_vec();
            let entry = match droidsaw_common::encoding::decode_mutf8(raw) {
                Ok(s) => crate::DexString::Decoded {
                    raw_bytes,
                    s,
                    declared_chars,
                    had_terminator,
                },
                Err(decode_error) => crate::DexString::new_malformed_mutf8(
                    raw_bytes,
                    decode_error,
                    declared_chars,
                    had_terminator,
                ),
            };
            strings.push(entry);
        }

        Ok((strings, data_offs))
    }

    fn parse_types(
        data: &[u8],
        header: &DexHeader,
        strings: &[crate::DexString],
    ) -> Result<Vec<String>> {
        let count = bound_count(
            header.type_ids_size,
            TYPE_ID_ITEM_SIZE,
            data.len(),
            "type_ids",
        )?;
        let mut types = Vec::with_capacity(count);
        let base = header.type_ids_off as usize;

        for i in 0..count {
            let off = safe_add(base, safe_mul(i, 4, "parser:parse_types:i*4")?, "parser:parse_types:off")?;
            let descriptor_idx: u32 =
                data.pread_with(off, LE).map_err(|e| DexError::ScrollRead {
                    offset: off,
                    source: e,
                })?;

            // Type descriptors are spec-required to be ASCII-shape
            // identifiers (Ljava/lang/Object;); a `MalformedMutf8`
            // descriptor would itself be a corrupt-DEX signal, but
            // tolerant-parse semantics materialize the lossy view here
            // (matches `parse_strings`'s `from_utf8_lossy` fallback shape).
            let descriptor = strings
                .get(descriptor_idx as usize)
                .ok_or(DexError::IndexOob {
                    pool: "String",
                    index: descriptor_idx,
                    pool_size: strings.len() as u32,
                })?
                .as_str_lossy()
                .to_string();
            types.push(descriptor);
        }

        Ok(types)
    }

    fn parse_protos(
        data: &[u8],
        header: &DexHeader,
        strings: &[crate::DexString],
        type_count: usize,
    ) -> Result<Vec<ProtoIdItem>> {
        let count = bound_count(
            header.proto_ids_size,
            PROTO_ID_ITEM_SIZE,
            data.len(),
            "proto_ids",
        )?;
        let mut protos = Vec::with_capacity(count);
        let base = header.proto_ids_off as usize;

        for i in 0..count {
            let off = safe_add(base, safe_mul(i, 12, "parser:parse_protos:i*12")?, "parser:parse_protos:off")?;
            let shorty_idx: u32 = data.pread_with(off, LE).map_err(|e| DexError::ScrollRead {
                offset: off,
                source: e,
            })?;
            let off_ret = safe_add(off, 4, "parser:parse_protos:off+4")?;
            let return_type_idx: u32 =
                data.pread_with(off_ret, LE)
                    .map_err(|e| DexError::ScrollRead {
                        offset: off_ret,
                        source: e,
                    })?;
            let off_params = safe_add(off, 8, "parser:parse_protos:off+8")?;
            let parameters_off: u32 =
                data.pread_with(off_params, LE)
                    .map_err(|e| DexError::ScrollRead {
                        offset: off_params,
                        source: e,
                    })?;

            if shorty_idx as usize >= strings.len() {
                return Err(DexError::IndexOob {
                    pool: "String",
                    index: shorty_idx,
                    pool_size: strings.len() as u32,
                });
            }
            if return_type_idx as usize >= type_count {
                return Err(DexError::IndexOob {
                    pool: "Type",
                    index: return_type_idx,
                    pool_size: type_count as u32,
                });
            }

            protos.push(ProtoIdItem {
                shorty_idx: StringIdx(shorty_idx),
                return_type_idx: TypeIdx(return_type_idx),
                parameters_off,
            });
        }

        Ok(protos)
    }

    /// Populate `out` with any interface-list offsets referenced by
    /// `class_defs` that aren't already covered by the proto-
    /// parameter lists. Uses the same on-disk layout as
    /// [`Self::parse_type_lists`] (u32 size followed by u16 TypeIdx
    /// array). Safe to call after `parse_type_lists`; duplicate
    /// offsets are silently skipped.
    ///
    /// Tolerant-parse contract: per-class_def parse errors are pushed
    /// to `parse_errors` as `ParseFailureKind::Interfaces` records and
    /// the offending class's interface list is dropped from `out`. Emit
    /// refuses to round-trip the partial IR via the standard
    /// `parse_errors.is_empty()` gate.
    fn extend_type_lists_from_interfaces(
        data: &[u8],
        class_defs: &[ClassDefItem],
        type_count: usize,
        out: &mut BTreeMap<u32, Vec<TypeIdx>>,
        parse_errors: &mut Vec<ParseFailure>,
    ) -> Result<()> {
        for cd in class_defs {
            let off = cd.interfaces_off;
            if off == 0 || out.contains_key(&off) {
                continue;
            }
            match Self::extend_one_interface_list(data, off, type_count, out) {
                Ok(()) => {}
                Err(_) => parse_errors.push(ParseFailure {
                    kind: ParseFailureKind::Interfaces,
                    offset: off,
                }),
            }
        }
        Ok(())
    }

    /// Single-class_def interface-list parse. Pulled out of
    /// [`Self::extend_type_lists_from_interfaces`] so the tolerant-
    /// record loop can catch per-entry errors without aborting the
    /// remaining classes.
    fn extend_one_interface_list(
        data: &[u8],
        off: u32,
        type_count: usize,
        out: &mut BTreeMap<u32, Vec<TypeIdx>>,
    ) -> Result<()> {
        let base = off as usize;
        let size: u32 = data
            .pread_with(base, LE)
            .map_err(|e| DexError::ScrollRead {
                offset: base,
                source: e,
            })?;
        let size = bound_count(size, TYPE_LIST_ITEM_SIZE, data.len(), "interface_list")?;
        let mut ifaces = Vec::with_capacity(size);
        for j in 0..size {
            let j2 = safe_mul(j, 2, "parser:extend_type_lists:j*2")?;
            let base4 = safe_add(base, 4, "parser:extend_type_lists:base+4")?;
            let idx_off = safe_add(base4, j2, "parser:extend_type_lists:idx_off")?;
            let type_idx: u16 =
                data.pread_with(idx_off, LE)
                    .map_err(|e| DexError::ScrollRead {
                        offset: idx_off,
                        source: e,
                    })?;
            if usize::from(type_idx) >= type_count {
                return Err(DexError::IndexOob {
                    pool: "Type",
                    index: u32::from(type_idx),
                    pool_size: type_count as u32,
                });
            }
            ifaces.push(TypeIdx(u32::from(type_idx)));
        }
        out.insert(off, ifaces);
        Ok(())
    }

    fn parse_type_lists(
        data: &[u8],
        protos: &[ProtoIdItem],
        type_count: usize,
    ) -> Result<BTreeMap<u32, Vec<TypeIdx>>> {
        let mut lists = BTreeMap::new();

        for proto in protos {
            let off = proto.parameters_off;
            if off == 0 || lists.contains_key(&off) {
                continue;
            }

            let base = off as usize;
            let size: u32 = data
                .pread_with(base, LE)
                .map_err(|e| DexError::ScrollRead {
                    offset: base,
                    source: e,
                })?;

            let size = bound_count(size, TYPE_LIST_ITEM_SIZE, data.len(), "type_list")?;
            let mut params = Vec::with_capacity(size);
            for j in 0..size {
                let j2 = safe_mul(j, 2, "parser:parse_type_lists:j*2")?;
                let base4 = safe_add(base, 4, "parser:parse_type_lists:base+4")?;
                let idx_off = safe_add(base4, j2, "parser:parse_type_lists:idx_off")?;
                let type_idx: u16 =
                    data.pread_with(idx_off, LE)
                        .map_err(|e| DexError::ScrollRead {
                            offset: idx_off,
                            source: e,
                        })?;
                if usize::from(type_idx) >= type_count {
                    return Err(DexError::IndexOob {
                        pool: "Type",
                        index: u32::from(type_idx),
                        pool_size: type_count as u32,
                    });
                }
                params.push(TypeIdx(u32::from(type_idx)));
            }
            lists.insert(off, params);
        }

        Ok(lists)
    }

    fn parse_fields(
        data: &[u8],
        header: &DexHeader,
        type_count: usize,
        string_count: usize,
    ) -> Result<Vec<FieldIdItem>> {
        let count = bound_count(
            header.field_ids_size,
            FIELD_ID_ITEM_SIZE,
            data.len(),
            "field_ids",
        )?;
        let mut fields = Vec::with_capacity(count);
        let base = header.field_ids_off as usize;

        for i in 0..count {
            let off = safe_add(base, safe_mul(i, 8, "parser:parse_fields:i*8")?, "parser:parse_fields:off")?;
            let class_idx: u16 = data.pread_with(off, LE).map_err(|e| DexError::ScrollRead {
                offset: off,
                source: e,
            })?;
            let off_type = safe_add(off, 2, "parser:parse_fields:off+2")?;
            let type_idx: u16 = data
                .pread_with(off_type, LE)
                .map_err(|e| DexError::ScrollRead {
                    offset: off_type,
                    source: e,
                })?;
            let off_name = safe_add(off, 4, "parser:parse_fields:off+4")?;
            let name_idx: u32 = data
                .pread_with(off_name, LE)
                .map_err(|e| DexError::ScrollRead {
                    offset: off_name,
                    source: e,
                })?;

            if class_idx as usize >= type_count {
                return Err(DexError::IndexOob {
                    pool: "Type",
                    index: u32::from(class_idx),
                    pool_size: type_count as u32,
                });
            }
            if type_idx as usize >= type_count {
                return Err(DexError::IndexOob {
                    pool: "Type",
                    index: u32::from(type_idx),
                    pool_size: type_count as u32,
                });
            }
            if name_idx as usize >= string_count {
                return Err(DexError::IndexOob {
                    pool: "String",
                    index: name_idx,
                    pool_size: string_count as u32,
                });
            }

            fields.push(FieldIdItem {
                class_idx: TypeIdx(u32::from(class_idx)),
                type_idx: TypeIdx(u32::from(type_idx)),
                name_idx: StringIdx(name_idx),
            });
        }

        Ok(fields)
    }

    fn parse_methods(
        data: &[u8],
        header: &DexHeader,
        type_count: usize,
        proto_count: usize,
        string_count: usize,
    ) -> Result<Vec<MethodIdItem>> {
        let count = bound_count(
            header.method_ids_size,
            METHOD_ID_ITEM_SIZE,
            data.len(),
            "method_ids",
        )?;
        let mut methods = Vec::with_capacity(count);
        let base = header.method_ids_off as usize;

        for i in 0..count {
            let off = safe_add(base, safe_mul(i, 8, "parser:parse_methods:i*8")?, "parser:parse_methods:off")?;
            let class_idx: u16 = data.pread_with(off, LE).map_err(|e| DexError::ScrollRead {
                offset: off,
                source: e,
            })?;
            let off_proto = safe_add(off, 2, "parser:parse_methods:off+2")?;
            let proto_idx: u16 =
                data.pread_with(off_proto, LE)
                    .map_err(|e| DexError::ScrollRead {
                        offset: off_proto,
                        source: e,
                    })?;
            let off_name = safe_add(off, 4, "parser:parse_methods:off+4")?;
            let name_idx: u32 = data
                .pread_with(off_name, LE)
                .map_err(|e| DexError::ScrollRead {
                    offset: off_name,
                    source: e,
                })?;

            if class_idx as usize >= type_count {
                return Err(DexError::IndexOob {
                    pool: "Type",
                    index: u32::from(class_idx),
                    pool_size: type_count as u32,
                });
            }
            if proto_idx as usize >= proto_count {
                return Err(DexError::IndexOob {
                    pool: "Proto",
                    index: u32::from(proto_idx),
                    pool_size: proto_count as u32,
                });
            }
            if name_idx as usize >= string_count {
                return Err(DexError::IndexOob {
                    pool: "String",
                    index: name_idx,
                    pool_size: string_count as u32,
                });
            }

            methods.push(MethodIdItem {
                class_idx: TypeIdx(u32::from(class_idx)),
                proto_idx: ProtoIdx(u32::from(proto_idx)),
                name_idx: StringIdx(name_idx),
            });
        }

        Ok(methods)
    }

    fn parse_class_defs(
        data: &[u8],
        header: &DexHeader,
        type_count: usize,
        string_count: usize,
    ) -> Result<Vec<ClassDefItem>> {
        let count = bound_count(
            header.class_defs_size,
            CLASS_DEF_ITEM_SIZE,
            data.len(),
            "class_defs",
        )?;
        let mut defs = Vec::with_capacity(count);
        let base = header.class_defs_off as usize;

        for i in 0..count {
            let off = safe_add(base, safe_mul(i, 32, "parser:parse_class_defs:i*32")?, "parser:parse_class_defs:off")?;
            let pread_u32 = |field_off: usize| -> Result<u32> {
                let o = safe_add(off, field_off, "parser:parse_class_defs:off+field_off")?;
                data.pread_with(o, LE)
                    .map_err(|e| DexError::ScrollRead {
                        offset: o,
                        source: e,
                    })
            };

            let class_idx = pread_u32(0)?;
            let access_flags = crate::access_flags::validate(
                pread_u32(4)?,
                crate::access_flags::AccessFlagScope::Class,
            )?;
            let superclass_raw = pread_u32(8)?;
            // The 4 class_def offset fields (`interfaces_off`,
            // `annotations_off`, `class_data_off`, `static_values_off`)
            // are NOT bounds-checked here. Each is consumed by a
            // downstream subsection parser whose error is captured into
            // `parse_errors` (Interfaces / AnnotationDirectory /
            // ClassData / EncodedArray respectively). This preserves the
            // tolerant-parse-and-record invariant uniformly across all
            // ten offset fields in the DEX format and keeps the
            // evasion-primitive defense single-chokepointed at the
            // emit-side `parse_errors.is_empty()` gate. `off = 0` is the
            // spec sentinel for "absent" and short-circuits all four
            // consumer paths.
            let interfaces_off = pread_u32(12)?;
            let source_file_raw = pread_u32(16)?;
            let annotations_off = pread_u32(20)?;
            let class_data_off = pread_u32(24)?;
            let static_values_off = pread_u32(28)?;

            if class_idx as usize >= type_count {
                return Err(DexError::IndexOob {
                    pool: "Type",
                    index: class_idx,
                    pool_size: type_count as u32,
                });
            }
            if let Some(TypeIdx(idx)) = optional_type_idx(superclass_raw) {
                if idx as usize >= type_count {
                    return Err(DexError::IndexOob {
                        pool: "Type",
                        index: idx,
                        pool_size: type_count as u32,
                    });
                }
            }
            if let Some(StringIdx(idx)) = optional_string_idx(source_file_raw) {
                if idx as usize >= string_count {
                    return Err(DexError::IndexOob {
                        pool: "String",
                        index: idx,
                        pool_size: string_count as u32,
                    });
                }
            }

            defs.push(ClassDefItem {
                class_idx: TypeIdx(class_idx),
                access_flags,
                superclass_idx: optional_type_idx(superclass_raw),
                interfaces_off,
                source_file_idx: optional_string_idx(source_file_raw),
                annotations_off,
                class_data_off,
                static_values_off,
            });
        }

        Ok(defs)
    }

    /// **Lossy** decoded `&str` for string `idx`. Returns the
    /// `Decoded` variant's `&str` on clean entries; returns the
    /// pre-computed `from_utf8_lossy` form on `MalformedMutf8`
    /// entries (U+FFFD substituted at every malformed byte). This
    /// is a convenience accessor for display / decompiler-emit /
    /// log-message paths that want a `&str` and tolerate U+FFFD
    /// substitution.
    ///
    /// # ⚠ Security-sensitive consumers: do NOT use this for scanning
    ///
    /// Byte-pattern scanners (YARA, trufflehog, AWS-key heuristics,
    /// any code that does `s.contains(b"...")` / `s.contains("...")`
    /// on attacker-influenced strings) must **NOT** consume this
    /// accessor — the U+FFFD substitution on `MalformedMutf8` entries
    /// (~11% of production DEXes in corpus measurements)
    /// loses the original bytes and constructs a silent scan-bypass
    /// primitive. Use one of:
    ///
    /// - [`Self::get_string_raw`] — returns `Option<&[u8]>` of the
    ///   on-disk MUTF-8 bytes; the canonical scan-safe path.
    /// - `dex.strings[idx].raw_bytes()` — same bytes via direct
    ///   `DexString` accessor.
    /// - `dex.strings[idx].decoded()?` — strict view; returns
    ///   `Err(EncodingError)` instead of substituting U+FFFD.
    ///
    /// # Strict consumers
    ///
    /// Code that requires well-formed Rust `&str` and is prepared to
    /// handle decode-failure explicitly should use
    /// `dex.strings[idx].decoded()?` directly, not this accessor.
    /// `get_string` always returns `Ok(&str)` (modulo OOB) regardless
    /// of decode outcome — by design — which makes strict semantics
    /// expressible only at the `DexString` accessor layer.
    ///
    /// Returns `Err(DexError::IndexOob)` on out-of-range index.
    pub fn get_string(&self, idx: StringIdx) -> Result<&str> {
        self.strings
            .get(idx.0 as usize)
            .map(crate::DexString::as_str_lossy)
            .ok_or(DexError::IndexOob {
                pool: "String",
                index: idx.0,
                pool_size: self.strings.len() as u32,
            })
    }

    /// Raw on-disk MUTF-8 bytes for string `idx`. Prefer this over
    /// [`Self::get_string`] when analysis is security-sensitive and
    /// must not consume U+FFFD substitutions that
    /// [`String::from_utf8_lossy`] introduces on invalid MUTF-8
    /// sequences. `get_string` returns the lossy-decoded `Cow<&str>`
    /// (~11% of real DEXes contain at least one such entry in corpus
    /// measurements); this returns the bytes exactly as stored on disk.
    ///
    /// Implementation: delegates to
    /// `self.strings.get(idx).map(DexString::raw_bytes)`. The bytes
    /// always exist — the underlying `DexString::raw_bytes()` is
    /// total — so this returns `Some` for every in-range index and
    /// `None` only on out-of-range.
    pub fn get_string_raw(&self, idx: StringIdx) -> Option<&[u8]> {
        self.strings.get(idx.0 as usize).map(crate::DexString::raw_bytes)
    }

    /// Return `Some(true)` iff `decode_mutf8` returned `Err` for the
    /// string at `idx` (i.e., the parser stored
    /// [`crate::DexString::MalformedMutf8`] and any display rendering
    /// substitutes U+FFFD via `from_utf8_lossy`). Use as a gate
    /// before pattern-matching on attacker-influenced string content.
    ///
    /// Returns:
    /// - `None` on out-of-range index (information-at-boundaries:
    ///   "no such string" is not the same as "string exists and
    ///   decoded cleanly").
    /// - `Some(false)` when the string exists and decoded cleanly.
    /// - `Some(true)` when the string exists and decode returned
    ///   `Err`. Raw bytes are available via [`Self::get_string_raw`].
    ///
    /// Implementation: delegates to
    /// `self.strings.get(idx).map(DexString::is_lossy)`.
    pub fn string_has_lossy_decode(&self, idx: StringIdx) -> Option<bool> {
        self.strings.get(idx.0 as usize).map(crate::DexString::is_lossy)
    }

    pub fn get_type_descriptor(&self, idx: TypeIdx) -> Result<&str> {
        self.type_descriptors
            .get(idx.0 as usize)
            .map(|s| s.as_str())
            .ok_or(DexError::IndexOob {
                pool: "Type",
                index: idx.0,
                pool_size: self.type_descriptors.len() as u32,
            })
    }

    /// Human-readable type name: `Ljava/lang/String;` → `java.lang.String`, `I` → `int`.
    pub fn pretty_type(&self, idx: TypeIdx) -> Result<String> {
        let desc = self.get_type_descriptor(idx)?;
        Ok(pretty_type_descriptor(desc))
    }

    /// Look up the parsed `DebugInfo` at the given `debug_info_off`, or
    /// `None` if the code_item had no debug_info or the per-entry parse
    /// failed (tolerant-skip; raw bytes may still be preserved in
    /// `debug_info_raw_bytes` for round-trip). `0` is the sentinel for
    /// "no debug info" in DEX code_items and always returns `None` here
    /// so callers don't need to guard.
    ///
    /// This is the preferred access surface for stack-trace
    /// symbolication, Frida hook placement, xref annotation, and similar
    /// RE tooling — parsing is eager at `DexFile::parse` time, so
    /// lookups are O(log n) BTreeMap hits with no per-call decode cost.
    pub fn debug_info(&self, debug_info_off: u32) -> Option<&crate::debug::DebugInfo> {
        if debug_info_off == 0 {
            return None;
        }
        // PROOF (audit BLOCK-1 minimum-set scoping): this is the lookup
        // primitive, not a detector. `None` correctly means "no debug
        // info for this offset" — caller decides whether None implies
        // "absent" (test/tooling callers) or "Indeterminate" (detector
        // callers that should additionally consult `subsection_clean`).
        // Detector consumers route their Indeterminate gate through
        // `annotation_subtree_verdict_gate` / `class_data_verdict_gate`
        // BEFORE calling this; gating here would falsely indeterminate-
        // ize every legitimate "no debug info" lookup.
        self.debug_infos.get(&debug_info_off)
    }

    /// Return the type indices listed in this method's
    /// `dalvik.annotation.Throws` annotation, or empty when no such
    /// annotation is present.
    ///
    /// Walks the class's `AnnotationsDirectory` for the method's
    /// per-method annotation set, scans for an `EncodedAnnotation`
    /// whose `type_idx` resolves to `Ldalvik/annotation/Throws;`, and
    /// decodes its `value` element as
    /// `EncodedValue::Array(Vec<EncodedValue::Type>)`.
    ///
    /// `annotations_off` is the owning `class_def.annotations_off` —
    /// pass `0` when the class has no annotations directory; this
    /// returns empty without any work. Caller in
    /// `classes.rs::emit_method` already has the `class_def` in scope,
    /// so this is a constant-extra-arg accessor rather than scanning
    /// every class to find the method's owner. Mirrors the
    /// `dex.debug_info_for_method` accessor pattern but optimised for
    /// the in-emit-loop call site (no class-scan per call).
    ///
    /// Returns empty `Vec` for the well-formed "no annotation" cases:
    /// missing directory, missing per-method entry, missing annotation
    /// item in the set, type isn't `Throws`, missing `value` element,
    /// `value` isn't an Array of Types. None of these are errors —
    /// they all mean "this method does not declare a throws clause."
    ///
    /// Errors only on truly malformed adversarial input (a `value`
    /// element whose StringIdx fails to resolve). Per non-negotiable
    /// #1 (no panics on adversarial input), every failable lookup
    /// uses `Option`/`Result` and propagates as `Err`. Empty result
    /// from a malformed `value`-array body type is treated as "no
    /// throws" rather than `Err` — preserving emit-side liveness on
    /// hostile DEX while still surfacing genuine string-pool corruption.
    pub fn method_throws(
        &self,
        annotations_off: u32,
        method_idx: MethodIdx,
    ) -> Result<Vec<TypeIdx>> {
        if annotations_off == 0 {
            return Ok(Vec::new());
        }
        // Detector-Indeterminate gate: if the annotation subtree had any
        // parse failure, we cannot honestly attest "no throws" — the
        // missing item might have been the @Throws annotation. Route as
        // typed Err so the caller's existing error path surfaces the
        // skip; the parallel Finding is emitted by
        // `diag::collect_detector_indeterminate_findings`.
        if !self.subsection_clean(Self::ANNOTATION_SUBSECTION_KINDS) {
            return Err(DexError::DetectorIndeterminate { context: "method_throws" });
        }
        let dir = match self.annotations.get(&annotations_off) {
            Some(d) => d,
            None => return Ok(Vec::new()),
        };
        let method_anno_off = match dir.methods.iter().find(|m| m.method_idx == method_idx) {
            Some(m) => m.annotations_off,
            None => return Ok(Vec::new()),
        };
        let anno_offsets = match self.annotation_sets.get(&method_anno_off) {
            Some(v) => v,
            None => return Ok(Vec::new()),
        };
        const THROWS_DESCRIPTOR: &str = "Ldalvik/annotation/Throws;";
        for &anno_off in anno_offsets {
            let item = match self.annotation_items.get(&anno_off) {
                Some(i) => i,
                None => continue,
            };
            let type_desc = match self.get_type_descriptor(item.annotation.type_idx) {
                Ok(s) => s,
                Err(_) => continue,
            };
            if type_desc != THROWS_DESCRIPTOR {
                continue;
            }
            // Found the Throws annotation. Locate the element whose
            // name resolves to "value" — that's the array of
            // throws-list types per the DEX spec.
            for (name_idx, value) in &item.annotation.elements {
                let name = self.get_string(*name_idx)?;
                if name != "value" {
                    continue;
                }
                let arr = match value {
                    crate::annotation::EncodedValue::Array(a) => a,
                    // `value` isn't an array — malformed or unexpected
                    // shape on adversarial DEX. Treat as "no throws"
                    // rather than panic.
                    _ => return Ok(Vec::new()),
                };
                let mut out = Vec::with_capacity(arr.len());
                for entry in arr {
                    if let crate::annotation::EncodedValue::Type(t) = entry {
                        out.push(*t);
                    }
                    // Non-Type entries inside the throws-list array
                    // are unexpected per DEX spec; skip them rather
                    // than fail the whole method.
                }
                return Ok(out);
            }
            // Throws annotation found but no `value` element — no
            // throws-list to emit. Stop scanning subsequent
            // annotations on this method (only one Throws annotation
            // is well-formed).
            return Ok(Vec::new());
        }
        Ok(Vec::new())
    }

    /// Convenience: look up `DebugInfo` for a method by `MethodIdx`.
    /// Resolves the method's code_item via `code_items`, then delegates
    /// to `debug_info`. Returns `None` when the method is abstract
    /// (no code), or the code_item has no debug_info, or the debug
    /// stream failed per-entry decode.
    pub fn debug_info_for_method(&self, idx: MethodIdx) -> Option<&crate::debug::DebugInfo> {
        // Locate the code_item for this method via the class_datas
        // inventory. Costs one scan over class_datas worst case — acceptable
        // for a lookup that's not on any hot path (RE tools query a handful
        // of methods, not millions).
        //
        // PROOF (audit BLOCK-1 minimum-set scoping): public lookup
        // primitive. None correctly means "no debug info for this
        // method" — abstract method, or `debug_info_off == 0`, or the
        // class_data was tolerantly skipped. Detector consumers gate
        // Indeterminate via `class_data_verdict_gate` / annotation gate
        // upstream; this accessor stays absent-vs-present.
        let code_off = self.class_datas.values().find_map(|cd| {
            cd.direct_methods
                .iter()
                .chain(cd.virtual_methods.iter())
                .find(|m| m.method_idx == idx)
                .map(|m| m.code_off)
        })?;
        if code_off == 0 {
            return None;
        }
        let ci = self.code_items.get(&code_off)?;
        self.debug_info(ci.debug_info_off)
    }

    /// Format a method as `ClassName.methodName(ParamType, ...): ReturnType`.
    pub fn format_method(&self, idx: MethodIdx) -> Result<String> {
        let m = self.methods.get(idx.0 as usize).ok_or(DexError::IndexOob {
            pool: "Method",
            index: idx.0,
            pool_size: self.methods.len() as u32,
        })?;
        let class = self.pretty_type(m.class_idx)?;
        let name = self.get_string(m.name_idx)?;
        let proto = self.protos.get(m.proto_idx.0 as usize).ok_or(DexError::IndexOob {
            pool: "Proto",
            index: m.proto_idx.0,
            pool_size: self.protos.len() as u32,
        })?;
        let ret = self.pretty_type(proto.return_type_idx)?;

        let params = if proto.parameters_off == 0 {
            String::new()
        } else {
            let type_list = self
                .type_lists
                .get(&proto.parameters_off)
                .cloned()
                .unwrap_or_default();
            type_list
                .iter()
                .map(|t| self.pretty_type(*t))
                .collect::<Result<Vec<_>>>()?
                .join(", ")
        };

        Ok(format!("{class}.{name}({params}): {ret}"))
    }

    /// Format a field as `ClassName.fieldName: Type`.
    pub fn format_field(&self, idx: FieldIdx) -> Result<String> {
        let f = self.fields.get(idx.0 as usize).ok_or(DexError::IndexOob {
            pool: "Field",
            index: idx.0,
            pool_size: self.fields.len() as u32,
        })?;
        let class = self.pretty_type(f.class_idx)?;
        let name = self.get_string(f.name_idx)?;
        let ty = self.pretty_type(f.type_idx)?;
        Ok(format!("{class}.{name}: {ty}"))
    }
}

/// DEX `map_item` row: `type_code:u16`, `unused:u16`, `size:u32`,
/// `offset:u32` — 12 bytes. The map list (pointed to by
/// `header.map_off`) starts with a `u32` count followed by that many
/// `map_item`s. Only a subset of type codes matter here; unrecognized
/// rows are ignored by callers.
///
/// Emit-side observation compares per-section emit offsets against the
/// original input offsets to distinguish spec-mandated re-emission from
/// canonicalization-driven section-boundary drift. Map entries carry
/// that source-of-truth.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MapEntry {
    pub type_code: u16,
    pub size: u32,
    pub offset: u32,
}

/// Validate a `class_def_item` offset field against `data.len()` at
/// parse time. `off = 0` is the spec sentinel for "absent" and is
/// always accepted. Any non-zero value must fall within the input.
///
/// **Currently unused at the parse site.** Earlier versions called this
/// at the class_def parse site to fail-closed on out-of-bounds offsets.
/// The current design uses tolerant-parse-and-record: each of the four
/// class_def offset fields (`interfaces_off`, `annotations_off`,
/// `class_data_off`, `static_values_off`) is consumed by a downstream
/// subsection parser whose error is captured into
/// `DexFile.parse_errors` (`Interfaces` / `AnnotationDirectory` /
/// `ClassData` / `EncodedArray` respectively). The evasion-primitive
/// defense is single-chokepointed at the emit-side
/// `parse_errors.is_empty()` gate, so analysts get partial-IR
/// introspection on malformed input while emit refuses to re-emit a
/// laundered DEX.
///
/// This helper is kept in source as the canonical bound-check formula
/// (gauge-locked by the Kani proof at
/// `proofs/class_def_off_bound.rs`) for any future caller that needs
/// the strict shape.
#[allow(
    dead_code,
    reason = "preserved as documented strict-shape helper; production parser uses tolerant-parse-and-record at downstream consumer sites instead, but the helper's bound-check contract is Kani-proven and available to any future strict-mode caller"
)]
#[inline]
pub(crate) fn validate_class_def_off(off: u32, field: &'static str, data_len: usize) -> Result<u32> {
    #[allow(clippy::as_conversions, reason = "PROOF: widen u32→usize for comparison against data_len (usize); lossless on 64-bit targets. Non-zero check precedes the cast.")]
    if off != 0 && (off as usize) >= data_len {
        return Err(DexError::ClassDefOffsetOutOfBounds {
            field,
            off,
            data_len,
        });
    }
    Ok(off)
}

/// Parse the DEX map list at `map_off`. Returns an empty vector when
/// `map_off` is zero (spec-permitted for files with no map list, though
/// that's rare) or the header is bounds-violating. Silently truncates
/// on partial reads — tolerant-parse non-negotiable.
#[allow(
    clippy::as_conversions,
    reason = "PROOF: three u32→usize widening casts, lossless on 64-bit targets. (1) `map_off as usize`: DEX header map_off is u32; widened to index into `data`. (2) `count.min(4096) as usize`: capped to 4096 before cast, well within usize range. (3) `count as usize`: loop bound from the u32 map-list count field; widening is lossless."
)]
fn read_map_list(data: &[u8], map_off: u32) -> Result<Vec<MapEntry>> {
    if map_off == 0 {
        return Ok(Vec::new());
    }
    let base = map_off as usize;
    let count: u32 = data
        .pread_with(base, LE)
        .map_err(|e| DexError::ScrollRead {
            offset: base,
            source: e,
        })?;
    let mut out = Vec::with_capacity(count.min(4096) as usize);
    for i in 0..count as usize {
        // Each map_item is 12 bytes; skip the leading u32 count.
        let Some(row_off) = i
            .checked_mul(12)
            .and_then(|step| step.checked_add(4))
            .and_then(|pre| base.checked_add(pre))
        else {
            break;
        };
        let Ok(type_code) = data.pread_with::<u16>(row_off, LE) else {
            break;
        };
        let Some(size_off) = row_off.checked_add(4) else {
            break;
        };
        let Ok(size) = data.pread_with::<u32>(size_off, LE) else {
            break;
        };
        let Some(off_off) = row_off.checked_add(8) else {
            break;
        };
        let Ok(offset) = data.pread_with::<u32>(off_off, LE) else {
            break;
        };
        out.push(MapEntry {
            type_code,
            size,
            offset,
        });
    }
    Ok(out)
}

/// Convert a DEX type descriptor to a human-readable name.
fn pretty_type_descriptor(desc: &str) -> String {
    match desc {
        "V" => "void".to_string(),
        "Z" => "boolean".to_string(),
        "B" => "byte".to_string(),
        "S" => "short".to_string(),
        "C" => "char".to_string(),
        "I" => "int".to_string(),
        "J" => "long".to_string(),
        "F" => "float".to_string(),
        "D" => "double".to_string(),
        s => {
            if let Some(rest) = s.strip_prefix('[') {
                format!("{}[]", pretty_type_descriptor(rest))
            } else if let Some(stripped) = s.strip_prefix('L').and_then(|s| s.strip_suffix(';')) {
                stripped.replace('/', ".")
            } else {
                s.to_string()
            }
        }
    }
}

#[allow(
    clippy::as_conversions,
    reason = "PROOF: every `idx.0 as usize` in this impl block is a u32→usize widen for `.get()` slice indexing on the DEX id-pools (methods, fields, protos, class_def_index). Lossless on all 64-bit targets; OOB is handled by the `let Some(...) = ... else { continue }` / `.get()?` guards that follow each cast."
)]
impl DexFile {
    /// Find all methods annotated with the given type descriptor.
    pub fn find_annotated_methods(
        &self,
        data: &[u8],
        annotation_type: &str,
    ) -> Result<Vec<MethodIdx>> {
        if !self.subsection_clean(Self::ANNOTATION_SUBSECTION_KINDS) {
            return Err(DexError::DetectorIndeterminate {
                context: "find_annotated_methods",
            });
        }
        let mut annotated_methods = Vec::new();

        for dir in self.annotations.values() {
            for method_ann in &dir.methods {
                if let Ok(set) =
                    crate::annotation::parse_annotation_set(data, method_ann.annotations_off)
                {
                    for item_off in set {
                        if let Ok(item) = crate::annotation::parse_annotation_item(data, item_off) {
                            if let Ok(desc) = self.get_type_descriptor(item.annotation.type_idx) {
                                if desc == annotation_type {
                                    annotated_methods.push(method_ann.method_idx);
                                }
                            }
                        }
                    }
                }
            }
        }

        Ok(annotated_methods)
    }

    /// Iterator over the super-class chain from `t` upward.
    ///
    /// Yields `t`'s superclass, then ITS superclass, etc. Does NOT yield
    /// `t` itself. Terminates cleanly on every benign exit:
    ///
    /// - the current type has no `class_def` in this DEX (external type
    ///   — chain is truncated rather than continuing past the boundary);
    /// - the current type's `superclass_idx` is `None` (typically
    ///   `java.lang.Object`);
    /// - the iteration depth exceeds the private `MAX_SUPER_CHAIN_DEPTH`
    ///   (defense against attacker-crafted cyclic super chains).
    ///
    /// Each hop is `O(class_defs.len())` (linear scan via the private
    /// `class_def_for_type` lookup). Hot callers
    /// that walk many chains should pre-build a
    /// [`crate::classes::TypeToClassDefMap`] and resolve directly. For
    /// recognizer one-shot use the linear scan is fine.
    pub fn super_class_chain(&self, t: TypeIdx) -> impl Iterator<Item = TypeIdx> + '_ {
        SuperClassChain {
            dex: self,
            current: Some(t),
            depth: 0,
        }
    }

    /// True iff `t`'s class def carries a `RuntimeVisible` annotation of
    /// type `Lkotlin/Metadata;` (the Kotlin compiler's source-origin
    /// marker on every Kotlin class).
    ///
    /// The Kotlin-source-origin gate for the `kotlinc19::when_sealed_*`
    /// recognizers — Java hand-written `instanceof` chains over a class
    /// hierarchy do NOT carry this annotation, so requiring it closes
    /// the false-positive risk on the sealed-`when` recognizers.
    ///
    /// Returns `false` for any benign absence — external type, no
    /// annotation directory, no class-level annotation set, missing
    /// annotation item, or annotation present but at a non-RUNTIME
    /// visibility. Every step is `.get()`-gated so the method never
    /// panics on malformed input.
    pub fn class_has_kotlin_metadata(&self, t: TypeIdx) -> crate::DetectorVerdict {
        use crate::DetectorVerdict;
        if let Some(v) = self.annotation_subtree_verdict_gate() {
            return v;
        }
        let Some(class_def) = self.class_def_for_type(t) else {
            return DetectorVerdict::No;
        };
        if class_def.annotations_off == 0 {
            return DetectorVerdict::No;
        }
        let Some(dir) = self.annotations.get(&class_def.annotations_off) else {
            return DetectorVerdict::No;
        };
        if dir.class_annotations_off == 0 {
            return DetectorVerdict::No;
        }
        let Some(set) = self.annotation_sets.get(&dir.class_annotations_off) else {
            return DetectorVerdict::No;
        };
        let hit = set.iter().any(|item_off| {
            self.annotation_items
                .get(item_off)
                .filter(|item| item.visibility == VISIBILITY_RUNTIME)
                .and_then(|item| self.get_type_descriptor(item.annotation.type_idx).ok())
                .is_some_and(|desc| desc == KOTLIN_METADATA_DESCRIPTOR)
        });
        if hit { DetectorVerdict::Yes } else { DetectorVerdict::No }
    }

    /// Lowest common ancestor in the super-class hierarchy across the
    /// supplied `arm_subtypes`.
    ///
    /// Returns `Some(<root>)` where `<root>` is the deepest type that
    /// appears in every subtype's super-class chain (NOT counting the
    /// subtypes themselves). Returns `None` when:
    ///
    /// - `arm_subtypes` is empty;
    /// - the first subtype's chain contains no type strictly below
    ///   `Ljava/lang/Object;` (no shared sealed-root candidate);
    /// - or no type strictly below `Ljava/lang/Object;` appears in
    ///   every other subtype's chain.
    ///
    /// `Ljava/lang/Object;` is explicitly excluded as the trivial root
    /// (every reference type shares it, so it carries no recognizer
    /// signal). Used by the `kotlinc19::when_sealed_*` recognizers to
    /// recover the `Discriminant::SealedSubtype { sealed_root }` from
    /// the per-arm subtypes a Kotlin `when` chain branches over.
    ///
    /// **Single-subtype edge:** `arm_subtypes.len() == 1` returns
    /// `Some(<T's first non-Object super>)` (degenerate LCA — the
    /// "appears in every other chain" requirement is vacuously
    /// satisfied with no other chains). Real callers always supply
    /// ≥2 subtypes per the sealed-`when` min-arm gate (≥3 per the
    /// `kotlinc19::when_sealed_*` recognizer brief), so this edge is
    /// unreachable in production; preserved for property-test
    /// soundness.
    pub fn sealed_root_lca(&self, arm_subtypes: &[TypeIdx]) -> Option<TypeIdx> {
        let first = arm_subtypes.first().copied()?;
        let candidates: Vec<TypeIdx> = self
            .super_class_chain(first)
            .filter(|&ty| {
                self.get_type_descriptor(ty)
                    .map(|d| d != OBJECT_DESCRIPTOR)
                    .unwrap_or(false)
            })
            .collect();
        if candidates.is_empty() {
            return None;
        }
        let other_chains: Vec<std::collections::HashSet<TypeIdx>> = arm_subtypes
            .get(1..)
            .unwrap_or(&[])
            .iter()
            .map(|&t| self.super_class_chain(t).collect())
            .collect();
        candidates
            .into_iter()
            .find(|c| other_chains.iter().all(|set| set.contains(c)))
    }

    /// Cheap-gate predicate for the kotlinc-1.9 top-level-fn synthetic
    /// class facade. Returns `true` when `t`'s descriptor ends with
    /// `Kt;` AND the class carries a `RuntimeVisible @kotlin.Metadata`
    /// annotation.
    ///
    /// kotlinc compiles a `.kt` file containing top-level functions or
    /// properties (with no class declaration) into a synthetic class
    /// named `<Filename>Kt`. The class:
    /// - is `final`,
    /// - has zero instance fields,
    /// - has zero virtual (instance) methods,
    /// - holds the file's top-level fns as `static final` direct
    ///   methods,
    /// - carries `@kotlin.Metadata`.
    ///
    /// **This predicate covers only the cheap part** of that gate
    /// (descriptor + metadata). The structural part — zero instance
    /// fields, zero virtual methods, all direct methods static — needs
    /// the parsed `class_data_item`, which lives outside `DexFile` and
    /// requires `data: &[u8]`. Callers (today: `decompile_class`) do
    /// the structural check at the use-site after `parse_class_data`.
    /// Splitting the gate this way keeps `DexFile` helpers pure
    /// dex-graph queries (consistent with `super_class_chain` /
    /// `class_has_kotlin_metadata` / `sealed_root_lca`).
    ///
    /// False-positive guard: a user-written `class FooKt` with
    /// `@kotlin.Metadata` (an idiomatic Kotlin class whose name
    /// happens to end in `Kt`) would pass the cheap gate but FAIL the
    /// structural gate, since real classes carry instance state or
    /// virtual methods. The two-stage gate closes the FP risk by
    /// construction.
    pub fn is_kotlin_facade_candidate(&self, t: TypeIdx) -> crate::DetectorVerdict {
        use crate::DetectorVerdict;
        let Ok(desc) = self.get_type_descriptor(t) else {
            return DetectorVerdict::No;
        };
        if !desc.ends_with(KOTLIN_FACADE_SUFFIX) {
            return DetectorVerdict::No;
        }
        self.class_has_kotlin_metadata(t)
    }

    /// Returns `true` iff `t` looks like a Kotlin `data class` per the
    /// kotlinc-1.9 lowering: (a) `@kotlin.Metadata` is present and (b) the
    /// class has both a `component1` virtual method and a `copy` virtual
    /// method. Both are auto-generated by kotlinc for every data class
    /// and have no source-level analogue outside the data-class lowering.
    ///
    /// Used by `classes.rs::decompile_class_impl` (PR-9d of #41b) to
    /// rewrite a Java-emitted data class as the Kotlin source-form
    /// header `data class Foo(val a: T1, val b: T2)`. Auto-generated
    /// members (componentN / copy / copy$default / equals / hashCode /
    /// toString / getX) are skipped from emit because kotlinc
    /// regenerates them on recompile.
    ///
    /// **False-positive guard**: a hand-written class that happens to
    /// declare `component1()` and `copy()` virtual methods while
    /// carrying `@kotlin.Metadata` is structurally indistinguishable from
    /// a real data class. In practice this combination is rare —
    /// `componentN` is a kotlinc compiler convention, not idiomatic
    /// hand-rolled API surface — and the fallback Java emit on a false
    /// negative is strictly more permissive (verbose but correct) than
    /// the data-class header.
    ///
    /// Cycle / depth: pure dex-graph query. Linear scan over
    /// `class_data.virtual_methods` capped by `class_data.virtual_methods.len()`.
    pub fn is_kotlin_data_class(&self, t: TypeIdx) -> crate::DetectorVerdict {
        use crate::DetectorVerdict;
        // Propagate Indeterminate from the metadata gate (annotation subtree
        // is tainted → we don't know if Kotlin marker is present).
        match self.class_has_kotlin_metadata(t) {
            DetectorVerdict::Yes => {}
            other => return other,
        }
        // class_data subtree taint check (we read class_data.virtual_methods).
        if let Some(v) = self.class_data_verdict_gate() {
            return v;
        }
        let Some(class_def) = self.class_def_for_type(t) else {
            return DetectorVerdict::No;
        };
        if class_def.class_data_off == 0 {
            return DetectorVerdict::No;
        }
        let Some(class_data) = self.class_datas.get(&class_def.class_data_off) else {
            return DetectorVerdict::No;
        };
        let mut has_component1 = false;
        let mut has_copy = false;
        for em in &class_data.virtual_methods {
            let Some(method) = self.methods.get(em.method_idx.0 as usize) else {
                continue;
            };
            let Ok(name) = self.get_string(method.name_idx) else {
                continue;
            };
            if name == KOTLIN_DATA_CLASS_COMPONENT1 {
                has_component1 = true;
            } else if name == KOTLIN_DATA_CLASS_COPY {
                has_copy = true;
            }
            if has_component1 && has_copy {
                return DetectorVerdict::Yes;
            }
        }
        DetectorVerdict::No
    }

    /// Returns `true` iff `t` looks like a Kotlin `sealed class` root
    /// per the kotlinc-1.9 lowering. Heuristic gate:
    ///
    /// 1. `@kotlin.Metadata` is present.
    /// 2. The class is `abstract` (`ACC_ABSTRACT` set).
    /// 3. The class has a private parameterless `<init>()` ctor AND a
    ///    `<init>(<root>; DefaultConstructorMarker)V`-shaped synthetic
    ///    ctor variant. Both legs together form the kotlinc-emitted
    ///    sealed-root pattern: the private real ctor + the synthetic
    ///    DefaultConstructorMarker variant that kotlinc routes
    ///    subclass `super(...)` calls through.
    ///
    /// **False-positive guard**: a hand-written abstract Kotlin class
    /// with a parameterless private ctor + a DefaultConstructorMarker
    /// ctor (which the user would have to manually declare) is
    /// indistinguishable structurally — but the marker class is
    /// `internal` to kotlinc and idiomatic source code never references
    /// it directly. Risk is negligible.
    ///
    /// Used by `classes.rs` (PR-9e of #41b) to rewrite the Java-emitted
    /// abstract class + Java-emitted subclasses as a single
    /// `sealed class Foo { object Sub : Foo(); class Sub2(val a: T) : Foo() }`
    /// block with subclass content inlined.
    ///
    /// Cycle / depth: pure dex-graph query. Linear scan over
    /// `class_data.direct_methods` capped by that vec's length.
    pub fn is_kotlin_sealed_root(&self, t: TypeIdx) -> crate::DetectorVerdict {
        use crate::DetectorVerdict;
        match self.class_has_kotlin_metadata(t) {
            DetectorVerdict::Yes => {}
            other => return other,
        }
        if let Some(v) = self.class_data_verdict_gate() {
            return v;
        }
        let Some(class_def) = self.class_def_for_type(t) else {
            return DetectorVerdict::No;
        };
        if class_def.access_flags & ACC_ABSTRACT == 0 {
            return DetectorVerdict::No;
        }
        if class_def.class_data_off == 0 {
            return DetectorVerdict::No;
        }
        let Some(class_data) = self.class_datas.get(&class_def.class_data_off) else {
            return DetectorVerdict::No;
        };
        let mut has_private_init = false;
        let mut has_marker_ctor = false;
        for em in &class_data.direct_methods {
            let Some(method) = self.methods.get(em.method_idx.0 as usize) else {
                continue;
            };
            let Ok(name) = self.get_string(method.name_idx) else {
                continue;
            };
            if name != INIT_METHOD_NAME {
                continue;
            }
            // Does this ctor accept exactly one DefaultConstructorMarker
            // parameter? That's the synthetic kotlinc-internal variant.
            let proto = match self.protos.get(method.proto_idx.0 as usize) {
                Some(p) => p,
                None => continue,
            };
            let type_list = if proto.parameters_off != 0 {
                self.type_lists.get(&proto.parameters_off)
            } else {
                None
            };
            let is_marker_ctor = match type_list {
                Some(tl) if tl.len() == 1 => {
                    let tidx = match tl.first() {
                        Some(t) => *t,
                        None => continue,
                    };
                    self.get_type_descriptor(tidx)
                        .map(|d| d == KOTLIN_DEFAULT_CONSTRUCTOR_MARKER_DESCRIPTOR)
                        .unwrap_or(false)
                }
                _ => false,
            };
            if is_marker_ctor {
                has_marker_ctor = true;
            } else if em.access_flags & ACC_PRIVATE != 0 && type_list.is_none() {
                // Private parameterless ctor.
                has_private_init = true;
            }
            if has_private_init && has_marker_ctor {
                return crate::DetectorVerdict::Yes;
            }
        }
        crate::DetectorVerdict::No
    }

    /// Returns `true` iff `t` looks like a Kotlin `object Sub : SealedRoot()`
    /// declaration — a singleton subclass of a sealed root. Heuristic gate:
    ///
    /// 1. `@kotlin.Metadata` is present.
    /// 2. Class is `final` (`ACC_FINAL` set).
    /// 3. Direct superclass `t.super` satisfies [`Self::is_kotlin_sealed_root`].
    /// 4. Class has a `INSTANCE` static field (the singleton holder).
    ///
    /// Sealed-OBJECT subclasses have NO instance state and a private
    /// no-arg ctor (plus a kotlinc-synthetic `<init>(DefaultConstructorMarker)`
    /// variant); sealed-CLASS subclasses (which carry source-level
    /// constructor parameters) lack the INSTANCE field. The INSTANCE
    /// field probe is the discriminator.
    ///
    /// PR-9e of #41b — drives subclass inlining as `object Sub : SealedRoot()`
    /// inside the parent's `sealed class` block.
    pub fn is_kotlin_sealed_object_subclass(&self, t: TypeIdx) -> crate::DetectorVerdict {
        use crate::DetectorVerdict;
        match self.class_has_kotlin_metadata(t) {
            DetectorVerdict::Yes => {}
            other => return other,
        }
        if let Some(v) = self.class_data_verdict_gate() {
            return v;
        }
        let Some(class_def) = self.class_def_for_type(t) else {
            return DetectorVerdict::No;
        };
        if class_def.access_flags & ACC_FINAL == 0 {
            return DetectorVerdict::No;
        }
        let Some(super_idx) = class_def.superclass_idx else {
            return DetectorVerdict::No;
        };
        match self.is_kotlin_sealed_root(super_idx) {
            DetectorVerdict::Yes => {}
            other => return other,
        }
        if class_def.class_data_off == 0 {
            return DetectorVerdict::No;
        }
        let Some(class_data) = self.class_datas.get(&class_def.class_data_off) else {
            return DetectorVerdict::No;
        };
        // Probe: is there a static field named `INSTANCE`?
        for ef in &class_data.static_fields {
            if ef.access_flags & ACC_STATIC == 0 {
                continue;
            }
            let Some(field) = self.fields.get(ef.field_idx.0 as usize) else {
                continue;
            };
            let Ok(name) = self.get_string(field.name_idx) else {
                continue;
            };
            if name == KOTLIN_INSTANCE_FIELD_NAME {
                return DetectorVerdict::Yes;
            }
        }
        DetectorVerdict::No
    }

    /// Returns `true` iff `t` looks like a Kotlin `class Sub(...) : SealedRoot()`
    /// declaration — a regular subclass of a sealed root carrying its
    /// own constructor properties. Heuristic gate:
    ///
    /// 1. `@kotlin.Metadata` is present.
    /// 2. Class is `final` (`ACC_FINAL` set).
    /// 3. Direct superclass `t.super` satisfies [`Self::is_kotlin_sealed_root`].
    /// 4. Class does NOT carry a `INSTANCE` static field (that would
    ///    indicate sealed-OBJECT, not sealed-CLASS).
    ///
    /// The `!INSTANCE` discriminator is the load-bearing distinction
    /// from `is_kotlin_sealed_object_subclass`. Sealed-CLASS subclasses
    /// have source-level ctor params (kotlinc emits `private final` fields
    /// + getters) and a non-trivial public ctor.
    ///
    /// PR-9e of #41b — drives subclass inlining as `class Sub(val a: T) : SealedRoot()`
    /// inside the parent's `sealed class` block.
    pub fn is_kotlin_sealed_class_subclass(&self, t: TypeIdx) -> crate::DetectorVerdict {
        use crate::DetectorVerdict;
        match self.class_has_kotlin_metadata(t) {
            DetectorVerdict::Yes => {}
            other => return other,
        }
        if let Some(v) = self.class_data_verdict_gate() {
            return v;
        }
        let Some(class_def) = self.class_def_for_type(t) else {
            return DetectorVerdict::No;
        };
        if class_def.access_flags & ACC_FINAL == 0 {
            return DetectorVerdict::No;
        }
        let Some(super_idx) = class_def.superclass_idx else {
            return DetectorVerdict::No;
        };
        match self.is_kotlin_sealed_root(super_idx) {
            DetectorVerdict::Yes => {}
            other => return other,
        }
        if class_def.class_data_off == 0 {
            return DetectorVerdict::No;
        }
        let Some(class_data) = self.class_datas.get(&class_def.class_data_off) else {
            return DetectorVerdict::No;
        };
        // Reject if INSTANCE field present — that's sealed-OBJECT.
        for ef in &class_data.static_fields {
            if ef.access_flags & ACC_STATIC == 0 {
                continue;
            }
            let Some(field) = self.fields.get(ef.field_idx.0 as usize) else {
                continue;
            };
            let Ok(name) = self.get_string(field.name_idx) else {
                continue;
            };
            if name == KOTLIN_INSTANCE_FIELD_NAME {
                return DetectorVerdict::No;
            }
        }
        DetectorVerdict::Yes
    }

    /// Enumerate every direct subclass of `root` that satisfies either
    /// [`Self::is_kotlin_sealed_object_subclass`] or
    /// [`Self::is_kotlin_sealed_class_subclass`]. Returns subclass `TypeIdx`s
    /// in declaration order (the order they appear in `class_defs`).
    ///
    /// Used by `classes.rs` (PR-9e) to inline sealed subclasses into
    /// the parent root's emit. The class iterator's filter additionally
    /// uses this membership to suppress the subclasses from being
    /// emitted as separate top-level Java classes.
    ///
    /// Cycle / depth: linear scan over `class_defs`; one
    /// `class_def_for_type` lookup per match candidate (which itself is
    /// linear, so total `O(n^2)` in the number of class_defs). For
    /// real-DEX corpus sizes (≤100k class_defs) this is acceptable per
    /// recognized-pass overhead — sealed-roots are rare so the inner
    /// short-circuits cheaply on the metadata + abstract gates.
    pub fn kotlin_sealed_subclasses(&self, root: TypeIdx) -> Result<Vec<TypeIdx>> {
        use crate::DetectorVerdict;
        let mut subs: Vec<TypeIdx> = Vec::new();
        match self.is_kotlin_sealed_root(root) {
            DetectorVerdict::Yes => {}
            DetectorVerdict::No => return Ok(subs),
            DetectorVerdict::Indeterminate => {
                return Err(DexError::DetectorIndeterminate {
                    context: "kotlin_sealed_subclasses",
                })
            }
        }
        for (i, cd) in self.class_defs.iter().enumerate() {
            // Shadow gate: a duplicate-class_idx row with the same
            // superclass would push the same TypeIdx twice and emit
            // would render the same subclass header twice, OR pick the
            // shadowed row's class_data while index-based resolution
            // picks the first — the polarity-flip primitive.
            if self.class_def_is_shadowed(i) {
                continue;
            }
            if cd.superclass_idx != Some(root) {
                continue;
            }
            if self.is_kotlin_sealed_object_subclass(cd.class_idx).is_yes()
                || self.is_kotlin_sealed_class_subclass(cd.class_idx).is_yes()
            {
                subs.push(cd.class_idx);
            }
        }
        Ok(subs)
    }

    /// Return `true` iff none of the supplied [`ParseFailureKind`]
    /// variants appear in [`Self::parse_errors`]. Detectors consume
    /// this gate before answering `Yes`/`No`; any reachable kind in
    /// the failure record yields [`crate::DetectorVerdict::Indeterminate`]
    /// because a tolerant-skipped subsection might have contained the
    /// signal the detector is searching for.
    ///
    /// Coarse by design — does not check whether the recorded
    /// `ParseFailure.offset` is reachable from the detector's query
    /// subtree. Offset-narrowed precision is a v1.x follow-up.
    #[must_use]
    pub fn subsection_clean(&self, kinds: &[ParseFailureKind]) -> bool {
        !self
            .parse_errors
            .iter()
            .any(|p| kinds.contains(&p.kind))
    }

    /// Kinds reachable from any detector that walks the annotation
    /// directory tree (`annotation_directory` → `annotation_set` /
    /// `annotation_set_ref_list` → `annotation_item`). Used by every
    /// detector that consults `self.annotations`, `self.annotation_sets`,
    /// `self.annotation_set_ref_lists`, or `self.annotation_items`.
    pub(crate) const ANNOTATION_SUBSECTION_KINDS: &'static [ParseFailureKind] = &[
        ParseFailureKind::AnnotationDirectory,
        ParseFailureKind::AnnotationSet,
        ParseFailureKind::AnnotationSetRefList,
        ParseFailureKind::AnnotationItem,
    ];

    /// Kinds reachable from any detector that walks per-class
    /// `class_data` (field + method inventories). Used by every
    /// detector that consults `self.class_datas`.
    pub(crate) const CLASS_DATA_SUBSECTION_KINDS: &'static [ParseFailureKind] =
        &[ParseFailureKind::ClassData];

    /// Helper: short-circuit shorthand for "annotation subtree clean OR
    /// return `Indeterminate`". Used by `class_has_kotlin_metadata`,
    /// `method_throws`, `find_annotated_methods`.
    fn annotation_subtree_verdict_gate(&self) -> Option<crate::DetectorVerdict> {
        if self.subsection_clean(Self::ANNOTATION_SUBSECTION_KINDS) {
            None
        } else {
            Some(crate::DetectorVerdict::Indeterminate)
        }
    }

    /// Helper: short-circuit shorthand for "class_data clean OR return
    /// `Indeterminate`". Used by `is_kotlin_data_class`,
    /// `is_kotlin_sealed_root`, `is_kotlin_sealed_object_subclass`,
    /// `is_kotlin_sealed_class_subclass`.
    fn class_data_verdict_gate(&self) -> Option<crate::DetectorVerdict> {
        if self.subsection_clean(Self::CLASS_DATA_SUBSECTION_KINDS) {
            None
        } else {
            Some(crate::DetectorVerdict::Indeterminate)
        }
    }

    /// Return the `ClassDefItem` whose `class_idx == t`, or `None` if no
    /// class def in this DEX defines `t` (external type).
    ///
    /// O(1) via `class_def_index` (populated at parse time). Falls back
    /// to a linear scan when the index is empty or its length doesn't
    /// match `type_descriptors.len()` — both shapes that only arise
    /// from hand-built test fixtures or post-parse mutations of
    /// `class_defs` without a corresponding [`Self::rebuild_class_def_index`]
    /// call. Production parse paths populate the index eagerly.
    fn class_def_for_type(&self, t: TypeIdx) -> Option<&ClassDefItem> {
        if self.class_def_index.len() == self.type_descriptors.len() {
            let idx = self.class_def_index.get(t.0 as usize).copied().flatten()?;
            return self.class_defs.get(idx as usize);
        }
        self.class_defs.iter().find(|cd| cd.class_idx == t)
    }

    /// Return `true` when `self.class_defs[class_defs_idx]` is shadowed
    /// by an earlier row sharing the same `class_idx` — i.e. it is NOT
    /// the row that `class_def_for_type` resolves for its type.
    ///
    /// Iteration callers (`classes_to_decompile`, `find_class`, enum-
    /// inline cache builders) gate on this to avoid the polarity-flip
    /// disagreement primitive: `class_def_for_type(T)` returns the
    /// FIRST row (per `rebuild_class_def_index`); a linear-scan caller
    /// that doesn't shadow-gate sees BOTH rows, surfacing both as
    /// independent classes while the index resolves only one. The
    /// disagreement is the attacker's signal-evasion lever.
    ///
    /// Out-of-range `class_defs_idx` returns `false`. Production
    /// callers iterate via `.enumerate()` over `class_defs`, so OOB
    /// never arises in normal use; the conservative default exists
    /// for hand-built test fixtures that index past `class_defs.len()`.
    ///
    /// O(1): one `class_def_index` lookup + integer compare.
    #[allow(
        clippy::as_conversions,
        reason = "PROOF: `cd.class_idx.0 as usize` widens u32→usize for the class_def_index .get(), bounded by class_def_index.len() == type_descriptors.len() (verified at rebuild_class_def_index time). Lossless on 32/64-bit targets."
    )]
    pub fn class_def_is_shadowed(&self, class_defs_idx: usize) -> bool {
        let Some(cd) = self.class_defs.get(class_defs_idx) else {
            return false;
        };
        let Some(slot) = self
            .class_def_index
            .get(cd.class_idx.0 as usize)
            .copied()
            .flatten()
        else {
            // Index not populated (test fixture without
            // rebuild_class_def_index call) or class_idx out of
            // type_descriptors range. Conservative: not shadowed.
            return false;
        };
        // The slot holds the class_defs index of the FIRST row for this
        // class_idx. If it points elsewhere, this row is a duplicate.
        // u32 → usize widen for the equality compare; class_defs.len()
        // is bounded by u32::MAX at parse time (rebuild guards with
        // `if i <= u32::MAX as usize`), so the cast is lossless.
        slot as usize != class_defs_idx
    }

    /// Rebuild [`Self::class_def_index`] from the current `class_defs`.
    /// Required after any post-parse mutation of `class_defs` (test
    /// fixtures only). One linear pass; idempotent.
    ///
    /// **First-wins on duplicate `class_idx`** (audit §H-9 + Wave 1
    /// re-review BLOCK-4). When two `class_def_item` rows share the same
    /// `class_idx`, the index slot pins to the FIRST encounter and a
    /// [`ParseFailureKind::DuplicateClassDef`] entry is appended to
    /// [`Self::parse_errors`] so `diag::collect_*` walkers can surface
    /// the divergence. Matches AOSP `DexFile::FindClassDef`'s first-wins
    /// resolution; closes the index-vs-iteration disagreement evasion
    /// primitive (pre-fix: index resolved LAST row while linear-scan
    /// callers saw both).
    pub fn rebuild_class_def_index(&mut self) {
        let mut idx = vec![None; self.type_descriptors.len()];
        // Accumulate duplicate-class_idx records here to push into
        // `self.parse_errors` after the immutable `self.class_defs`
        // borrow ends. The class_defs index `i` is recorded as the
        // ParseFailure.offset for triage (not the binary file offset —
        // that's an internal-index marker).
        let mut duplicates: Vec<u32> = Vec::new();
        for (i, cd) in self.class_defs.iter().enumerate() {
            if let Some(slot) = idx.get_mut(cd.class_idx.0 as usize) {
                if i <= u32::MAX as usize {
                    #[allow(
                        clippy::cast_possible_truncation,
                        reason = "PROOF: guarded by `i <= u32::MAX as usize` on the line above."
                    )]
                    let i_u32 = i as u32;
                    match *slot {
                        None => {
                            *slot = Some(i_u32);
                        }
                        Some(_prior) => {
                            // First-wins: keep the existing slot;
                            // record the duplicate for diag surfacing.
                            duplicates.push(i_u32);
                        }
                    }
                }
            }
        }
        self.class_def_index = idx;
        for class_defs_idx in duplicates {
            self.parse_errors.push(ParseFailure {
                kind: ParseFailureKind::DuplicateClassDef,
                offset: class_defs_idx,
            });
        }
    }

    /// Extract a [`crate::parser_oracle::DexParseShape`] from the production
    /// parse result for differential comparison against the naive oracle.
    ///
    /// Only compiled under `#[cfg(any(test, kani, fuzzing))]` — same gate as
    /// the oracle module. Not available in production builds.
    #[cfg(any(test, kani, fuzzing))]
    #[allow(
        clippy::cast_possible_truncation,
        reason = "PROOF: `i` is a strings.iter() index. DEX string table size is u32-bounded by the file-format header (string_ids_size: u32). Narrowing usize position to u32 is exact for spec-compliant inputs; if not, `unwrap_or(u32::MAX)` falls through and the shape divergence will be reported by the oracle."
    )]
    pub fn to_shape(&self) -> crate::parser_oracle::DexParseShape {
        crate::parser_oracle::DexParseShape {
            header_magic: self.header.magic,
            string_ids_size: self.header.string_ids_size,
            string_ids_off: self.header.string_ids_off,
            type_ids_size: self.header.type_ids_size,
            type_ids_off: self.header.type_ids_off,
            class_defs_size: self.header.class_defs_size,
            class_defs_off: self.header.class_defs_off,
            // Raw MUTF-8 bytes (pre-decode) for each string-pool entry.
            // Source-of-truth for byte-pattern oracles; matches the
            // pre-`DexString` `string_raw_bytes` Vec<Vec<u8>> shape.
            strings: self.strings.iter().map(|e| e.raw_bytes().to_vec()).collect(),
            // type_id_item descriptor_idx values in pool order.
            // Production parse_types resolves each descriptor_idx → string;
            // we reconstruct the raw indices by mapping back from the
            // resolved descriptor string to its position in the string pool.
            // This is not a new parse — the data is already in DexFile.
            //
            // Production stores type_descriptors as Vec<String> (resolved).
            // Oracle stores type_descriptors as Vec<u32> (raw indices).
            // We must reconstruct the raw u32 from the resolved string.
            //
            // The reconstruction does a reverse lookup: for each resolved
            // descriptor string, find its index in the string pool. This
            // is O(N^2) but only runs under test/fuzz — not in production.
            type_descriptors: self
                .type_descriptors
                .iter()
                .map(|desc| {
                    // Find the first matching string-pool index.
                    // Production guarantees each descriptor is a valid pool
                    // entry; if not found (impossible in production), use
                    // u32::MAX as a sentinel (will cause shape divergence,
                    // which is the correct signal — oracle would also fail
                    // to find a valid descriptor_idx).
                    self.strings
                        .iter()
                        .position(|s| s.as_str_lossy() == desc.as_str())
                        .map(|i| i as u32)
                        .unwrap_or(u32::MAX)
                })
                .collect(),
            // class_def_item class_idx values in class_defs order.
            class_def_class_types: self
                .class_defs
                .iter()
                .map(|cd| cd.class_idx.0)
                .collect(),
        }
    }

    /// Empty-`DexFile` constructor for fuzz / test harnesses that need a
    /// `&DexFile` parameter but operate on the raw byte stream
    /// (e.g. `parse_debug_info`'s sub-parser fuzz target). Constructs a
    /// near-minimal DexFile whose string + type pools are empty — name
    /// resolution lookups against this instance will return `None`,
    /// which is the contract the debug-info state machine expects when
    /// referenced indices are out of bounds. Gated on `cfg(test)` or
    /// `cfg(fuzzing)` so it cannot be reached from production callers.
    #[cfg(any(test, fuzzing))]
    pub fn empty_for_fuzz() -> DexFile {
        use std::collections::BTreeMap;
        DexFile {
            header: crate::header::DexHeader {
                magic: *b"dex\n035\0",
                checksum: 0,
                signature: [0u8; 20],
                file_size: 0,
                header_size: 112,
                endian_tag: 0x1234_5678,
                link_size: 0,
                link_off: 0,
                map_off: 0,
                string_ids_size: 0,
                string_ids_off: 0,
                type_ids_size: 0,
                type_ids_off: 0,
                proto_ids_size: 0,
                proto_ids_off: 0,
                field_ids_size: 0,
                field_ids_off: 0,
                method_ids_size: 0,
                method_ids_off: 0,
                class_defs_size: 0,
                class_defs_off: 0,
                data_size: 0,
                data_off: 0,
            },
            string_data_offs: Vec::new(),
            strings: Vec::new(),
            type_descriptors: Vec::new(),
            protos: Vec::new(),
            fields: Vec::new(),
            methods: Vec::new(),
            class_defs: Vec::new(),
            annotations: BTreeMap::new(),
            type_lists: BTreeMap::new(),
            class_datas: BTreeMap::new(),
            raw_class_data_bytes: BTreeMap::new(),
            code_items: BTreeMap::new(),
            annotation_sets: BTreeMap::new(),
            annotation_set_ref_lists: BTreeMap::new(),
            annotation_items: BTreeMap::new(),
            annotation_item_widths: BTreeMap::new(),
            encoded_arrays: BTreeMap::new(),
            encoded_array_widths: BTreeMap::new(),
            method_handles: Vec::new(),
            call_site_ids: Vec::new(),
            map_entries: Vec::new(),
            debug_infos: BTreeMap::new(),
            debug_info_raw_bytes: BTreeMap::new(),
            debug_info_section_layout: Vec::new(),
            annotation_set_section_layout: Vec::new(),
            input_checksums_canonical: true,
            parse_errors: Vec::new(),
            class_def_index: Vec::new(),
        }
    }
}

/// Iterator backing [`DexFile::super_class_chain`]. Walks one super-class
/// hop per `next()` call; tracks depth to terminate on cyclic chains
/// after [`MAX_SUPER_CHAIN_DEPTH`] hops.
struct SuperClassChain<'a> {
    dex: &'a DexFile,
    /// Type whose superclass will be yielded next. `None` when the chain
    /// has terminated (external type, reached Object, or depth-capped).
    current: Option<TypeIdx>,
    depth: usize,
}

impl Iterator for SuperClassChain<'_> {
    type Item = TypeIdx;

    fn next(&mut self) -> Option<TypeIdx> {
        if self.depth >= MAX_SUPER_CHAIN_DEPTH {
            return None;
        }
        let cur = self.current?;
        let cd = self.dex.class_def_for_type(cur)?;
        let sup = cd.superclass_idx?;
        self.current = Some(sup);
        self.depth = self.depth.saturating_add(1);
        Some(sup)
    }
}

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

    #[test]
    fn debug_info_accessor_matches_ad_hoc_parse() {
        // Round-trip: the `DexFile::debug_info` accessor must return the
        // same DebugInfo that an ad-hoc `debug::parse_debug_info` call at
        // the same offset would produce. Proves the eager-parse path in
        // `DexFile::parse` loses no information relative to the pre-hoist
        // per-method re-parse pattern that `decompile_method` used to do.
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let dex = DexFile::parse(data, None).unwrap();

        let mut checked = 0usize;
        for ci in dex.code_items.values() {
            if ci.debug_info_off == 0 {
                continue;
            }
            let via_accessor = dex.debug_info(ci.debug_info_off);
            let via_ad_hoc = crate::debug::parse_debug_info(
                data,
                ci.debug_info_off,
                ci.registers_size,
                &dex,
            )
            .ok();
            assert_eq!(
                via_accessor,
                via_ad_hoc.as_ref(),
                "accessor diverged from ad-hoc parse at off={:#x}",
                ci.debug_info_off
            );
            checked += 1;
        }
        assert!(checked > 0, "fixture must have at least one debug-info-bearing method");
    }

    #[test]
    fn debug_info_zero_offset_returns_none() {
        // Sentinel: DEX uses 0 to mean "no debug_info" in code_items.
        // The accessor must return None without looking up the map.
        let data = include_bytes!("../../tests/fixtures/classes.dex");
        let dex = DexFile::parse(data, None).unwrap();
        assert!(dex.debug_info(0).is_none());
    }

    #[test]
    fn debug_info_for_method_resolves_via_method_idx() {
        // Ergonomic accessor: DebugInfo lookup via MethodIdx should
        // resolve to the same entry as the raw-offset form.
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let dex = DexFile::parse(data, None).unwrap();

        // Find MinimalNamed.accumulate — the method with named locals.
        // Grab its method_idx + code_off.
        let (method_idx, code_off) = dex
            .class_datas
            .values()
            .find_map(|cd| {
                cd.direct_methods
                    .iter()
                    .chain(cd.virtual_methods.iter())
                    .find(|m| {
                        dex.methods
                            .get(m.method_idx.0 as usize)
                            .and_then(|mi| dex.get_string(mi.name_idx).ok())
                            == Some("accumulate")
                    })
                    .map(|m| (m.method_idx, m.code_off))
            })
            .expect("accumulate method not found");

        let ci = dex.code_items.get(&code_off).expect("code_item present");
        assert_ne!(
            ci.debug_info_off, 0,
            "accumulate should ship with debug_info after `javac -g`"
        );

        let by_offset = dex.debug_info(ci.debug_info_off).expect("offset-based hit");
        let by_method = dex
            .debug_info_for_method(method_idx)
            .expect("method-based hit");
        assert_eq!(by_offset, by_method, "both accessors must agree");

        // Sanity: the DebugInfo carries expected source-level locals.
        let names: Vec<&str> = by_method
            .locals
            .iter()
            .filter_map(|l| l.name.as_deref())
            .collect();
        for want in ["counter", "total", "i"] {
            assert!(names.contains(&want), "locals must contain `{want}`; got {names:?}");
        }
    }

    #[test]
    fn pretty_type_primitives() {
        assert_eq!(pretty_type_descriptor("I"), "int");
        assert_eq!(pretty_type_descriptor("V"), "void");
        assert_eq!(pretty_type_descriptor("Z"), "boolean");
        assert_eq!(pretty_type_descriptor("J"), "long");
    }

    #[test]
    fn pretty_type_class() {
        assert_eq!(
            pretty_type_descriptor("Ljava/lang/String;"),
            "java.lang.String"
        );
    }

    #[test]
    fn pretty_type_array() {
        assert_eq!(pretty_type_descriptor("[I"), "int[]");
        assert_eq!(pretty_type_descriptor("[[B"), "byte[][]");
        assert_eq!(
            pretty_type_descriptor("[Ljava/lang/Object;"),
            "java.lang.Object[]"
        );
    }

    #[test]
    fn raw_string_accessor_matches_decoded_on_clean_fixture() {
        // Minimal fixture has only ASCII strings — raw bytes and
        // decoded .as_bytes() must match for every entry. Proves the
        // accessor returns what's actually on disk (not a fallback).
        let data = include_bytes!("../../tests/fixtures/classes.dex");
        let dex = DexFile::parse(data, None).expect("parse");
        assert!(!dex.strings.is_empty());
        for i in 0..dex.strings.len() {
            let idx = StringIdx(i as u32);
            let raw = dex.get_string_raw(idx).expect("raw side-table populated");
            let decoded = dex.get_string(idx).expect("decoded");
            assert_eq!(
                raw,
                decoded.as_bytes(),
                "raw/decoded disagree at idx {i} on ASCII-only fixture"
            );
            assert_eq!(
                dex.string_has_lossy_decode(idx),
                Some(false),
                "ASCII-only fixture should report Some(false) at idx {i} \
                 (string exists and decoded cleanly)"
            );
        }
    }

    #[test]
    fn super_class_chain_terminates_on_every_class_def() {
        // Property: every chain in the fixture must terminate. The
        // depth-cap defense means even a maliciously cyclic chain would
        // halt at MAX_SUPER_CHAIN_DEPTH; clean Java fixtures terminate
        // before that limit (Object's superclass_idx is None).
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let dex = DexFile::parse(data, None).expect("parse");
        assert!(
            !dex.class_defs.is_empty(),
            "fixture must contain at least one class_def"
        );
        for cd in &dex.class_defs {
            let chain: Vec<TypeIdx> = dex.super_class_chain(cd.class_idx).collect();
            assert!(
                chain.len() < MAX_SUPER_CHAIN_DEPTH,
                "chain for {} hit the depth cap — likely a cycle",
                dex.get_type_descriptor(cd.class_idx).unwrap_or("?")
            );
            // Iterator never yields the start type itself.
            assert!(
                !chain.contains(&cd.class_idx),
                "chain for {} contains start type",
                dex.get_type_descriptor(cd.class_idx).unwrap_or("?")
            );
        }
    }

    #[test]
    fn class_has_kotlin_metadata_returns_false_on_java_fixtures() {
        // The fixture is javac-compiled — no class should carry
        // `Lkotlin/Metadata;`. False-positive ratchet for the
        // `kotlinc19::when_sealed_*` metadata gate: if this ever returns
        // true on a Java fixture, the recognizer's metadata gate is
        // unsound (Java code matching a Kotlin recognizer).
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let dex = DexFile::parse(data, None).expect("parse");
        for cd in &dex.class_defs {
            assert!(
                dex.class_has_kotlin_metadata(cd.class_idx).is_no(),
                "class {} unexpectedly carries @kotlin.Metadata",
                dex.get_type_descriptor(cd.class_idx).unwrap_or("?")
            );
        }
    }

    #[test]
    fn sealed_root_lca_empty_input_returns_none() {
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let dex = DexFile::parse(data, None).expect("parse");
        assert_eq!(dex.sealed_root_lca(&[]), None);
    }

    #[test]
    fn sealed_root_lca_self_pair_returns_first_non_object_super() {
        // Property: for every class T in the fixture, sealed_root_lca([T, T])
        // must equal the first non-Object element of T's super-class chain
        // (or None when no such element exists). Cross-checks the LCA
        // logic against the iterator: same primitive, same answer.
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let dex = DexFile::parse(data, None).expect("parse");
        for cd in &dex.class_defs {
            let t = cd.class_idx;
            let expected = dex
                .super_class_chain(t)
                .find(|&ty| {
                    dex.get_type_descriptor(ty)
                        .map(|d| d != OBJECT_DESCRIPTOR)
                        .unwrap_or(false)
                });
            assert_eq!(
                dex.sealed_root_lca(&[t, t]),
                expected,
                "self-LCA mismatch for class {}",
                dex.get_type_descriptor(t).unwrap_or("?")
            );
        }
    }

    #[test]
    fn sealed_root_lca_excludes_object() {
        // Spec: Object is the trivial root and MUST NOT be returned.
        // Across every pair of class_defs, if the LCA returns Some(_),
        // its descriptor must not be `Ljava/lang/Object;`.
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let dex = DexFile::parse(data, None).expect("parse");
        for (i, a) in dex.class_defs.iter().enumerate() {
            for b in &dex.class_defs[i + 1..] {
                if let Some(root) = dex.sealed_root_lca(&[a.class_idx, b.class_idx]) {
                    let desc = dex.get_type_descriptor(root).unwrap_or("?");
                    assert_ne!(
                        desc, OBJECT_DESCRIPTOR,
                        "LCA returned Object for ({}, {})",
                        dex.get_type_descriptor(a.class_idx).unwrap_or("?"),
                        dex.get_type_descriptor(b.class_idx).unwrap_or("?")
                    );
                }
            }
        }
    }

    #[test]
    fn is_kotlin_facade_candidate_returns_false_on_java_fixtures() {
        // Tightening of the false-positive ratchet for the facade gate.
        // Java fixtures don't carry `@kotlin.Metadata`, so even a class
        // named `FooKt` (none in the fixture, but a defensive check)
        // would fail the metadata leg. This is the cheap-gate; the
        // structural gate at emit time tightens further.
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let dex = DexFile::parse(data, None).expect("parse");
        for cd in &dex.class_defs {
            assert!(
                dex.is_kotlin_facade_candidate(cd.class_idx).is_no(),
                "class {} unexpectedly matched the facade candidate gate",
                dex.get_type_descriptor(cd.class_idx).unwrap_or("?")
            );
        }
    }

    #[test]
    fn is_kotlin_facade_candidate_requires_kt_suffix() {
        // Empirical: kotlinc-1.9.22 lowers `Foo.kt` containing only
        // top-level fns into a class named `LFooKt;`. The cheap-gate's
        // first leg is the descriptor suffix `Kt;`. A class without
        // that suffix MUST fail even if it somehow carried metadata.
        // (Java fixtures don't carry metadata, so the second leg also
        // fails — but the first-leg short-circuit is what we exercise
        // here for a tighter unit signal.)
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let dex = DexFile::parse(data, None).expect("parse");
        for cd in &dex.class_defs {
            let desc = dex.get_type_descriptor(cd.class_idx).unwrap_or("?");
            if !desc.ends_with(KOTLIN_FACADE_SUFFIX) {
                assert!(
                    dex.is_kotlin_facade_candidate(cd.class_idx).is_no(),
                    "class {} matched facade gate without `Kt;` suffix",
                    desc
                );
            }
        }
    }

    #[test]
    fn is_kotlin_data_class_returns_false_on_java_fixtures() {
        // The bundled Java fixture has no `@kotlin.Metadata` and no
        // `componentN`/`copy` methods, so the data-class gate must
        // return false on every class. The metadata leg short-circuits
        // before the structural scan, so this also exercises the
        // cheap-gate path.
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let dex = DexFile::parse(data, None).expect("parse");
        for cd in &dex.class_defs {
            assert!(
                dex.is_kotlin_data_class(cd.class_idx).is_no(),
                "class {} unexpectedly matched the data-class gate",
                dex.get_type_descriptor(cd.class_idx).unwrap_or("?")
            );
        }
    }

    #[test]
    fn is_kotlin_data_class_returns_false_without_class_data() {
        // Fabricate a TypeIdx that has no class_def in this DEX
        // (external type). The predicate must short-circuit cleanly,
        // returning false rather than panicking on the missing entry.
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let dex = DexFile::parse(data, None).expect("parse");
        let unknown = TypeIdx(u32::MAX);
        assert!(dex.is_kotlin_data_class(unknown).is_no());
    }

    #[test]
    fn is_kotlin_sealed_root_returns_false_on_java_fixtures() {
        // Java fixtures have no `@kotlin.Metadata` and no
        // DefaultConstructorMarker ctors, so the sealed-root gate
        // must return false on every class. The metadata leg
        // short-circuits before the structural scan.
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let dex = DexFile::parse(data, None).expect("parse");
        for cd in &dex.class_defs {
            assert!(
                dex.is_kotlin_sealed_root(cd.class_idx).is_no(),
                "class {} unexpectedly matched the sealed-root gate",
                dex.get_type_descriptor(cd.class_idx).unwrap_or("?")
            );
        }
    }

    #[test]
    fn is_kotlin_sealed_object_subclass_returns_false_on_java_fixtures() {
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let dex = DexFile::parse(data, None).expect("parse");
        for cd in &dex.class_defs {
            assert!(
                dex.is_kotlin_sealed_object_subclass(cd.class_idx).is_no(),
                "class {} unexpectedly matched the sealed-object-subclass gate",
                dex.get_type_descriptor(cd.class_idx).unwrap_or("?")
            );
        }
    }

    #[test]
    fn is_kotlin_sealed_class_subclass_returns_false_on_java_fixtures() {
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let dex = DexFile::parse(data, None).expect("parse");
        for cd in &dex.class_defs {
            assert!(
                dex.is_kotlin_sealed_class_subclass(cd.class_idx).is_no(),
                "class {} unexpectedly matched the sealed-class-subclass gate",
                dex.get_type_descriptor(cd.class_idx).unwrap_or("?")
            );
        }
    }

    #[test]
    fn kotlin_sealed_subclasses_empty_on_non_root() {
        // Calling on a class that isn't a sealed root must return
        // an empty Vec rather than panic. The is_kotlin_sealed_root
        // gate at the helper's entry short-circuits the scan.
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let dex = DexFile::parse(data, None).expect("parse");
        for cd in &dex.class_defs {
            let subs = dex
                .kotlin_sealed_subclasses(cd.class_idx)
                .expect("clean fixture has no parse_errors → Ok");
            assert!(
                subs.is_empty(),
                "class {} unexpectedly returned {} sealed subclasses",
                dex.get_type_descriptor(cd.class_idx).unwrap_or("?"),
                subs.len()
            );
        }
    }

    #[test]
    fn is_kotlin_sealed_root_returns_false_on_unknown_type() {
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let dex = DexFile::parse(data, None).expect("parse");
        let unknown = TypeIdx(u32::MAX);
        assert!(dex.is_kotlin_sealed_root(unknown).is_no());
        assert!(dex.is_kotlin_sealed_object_subclass(unknown).is_no());
        assert!(dex.is_kotlin_sealed_class_subclass(unknown).is_no());
        assert!(dex
            .kotlin_sealed_subclasses(unknown)
            .expect("clean fixture → Ok")
            .is_empty());
    }

    // ── parse_errors poison-pill: detectors return Indeterminate ─────────
    //
    // These tests verify the C-1 fix: a `ParseFailure` recorded for a
    // subsection-kind reachable from a detector path MUST cause the
    // detector to return `Indeterminate`, never `No`. The contract is
    // the structural-fix shape — even if a benign fixture is paired
    // with a planted `parse_errors` entry, every detector must surface
    // the poison signal rather than collapse to false.

    #[test]
    fn class_has_kotlin_metadata_indeterminate_on_annotation_item_parse_failure() {
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let mut dex = DexFile::parse(data, None).expect("parse");
        dex.parse_errors.push(ParseFailure {
            kind: ParseFailureKind::AnnotationItem,
            offset: 0x1000,
        });
        for cd in &dex.class_defs {
            assert!(
                dex.class_has_kotlin_metadata(cd.class_idx).is_indeterminate(),
                "class {} returned non-Indeterminate despite planted parse_errors",
                dex.get_type_descriptor(cd.class_idx).unwrap_or("?")
            );
        }
    }

    #[test]
    fn is_kotlin_data_class_never_silently_yes_on_class_data_parse_failure() {
        // The Java fixture has no @kotlin.Metadata, so the metadata gate
        // returns No and short-circuits before class_data is read; the
        // class_data taint is therefore not reachable from the detector's
        // effective query on this fixture and the verdict is No (correct).
        // Contract under test: the detector NEVER silently returns Yes
        // when class_data is poisoned. The fixture-without-metadata case
        // is the strictest test we can write absent a Kotlin fixture.
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let mut dex = DexFile::parse(data, None).expect("parse");
        dex.parse_errors.push(ParseFailure {
            kind: ParseFailureKind::ClassData,
            offset: 0x2000,
        });
        for cd in &dex.class_defs {
            let verdict = dex.is_kotlin_data_class(cd.class_idx);
            assert!(
                !verdict.is_yes(),
                "class {} returned Yes despite planted class_data parse_errors",
                dex.get_type_descriptor(cd.class_idx).unwrap_or("?")
            );
        }
    }

    #[test]
    fn is_kotlin_sealed_root_indeterminate_on_annotation_set_parse_failure() {
        // Annotation-set failure propagates through the metadata gate
        // even though sealed_root reads class_data — `class_has_kotlin_metadata`
        // is the first leg and returns Indeterminate, which then
        // short-circuits the sealed_root walker.
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let mut dex = DexFile::parse(data, None).expect("parse");
        dex.parse_errors.push(ParseFailure {
            kind: ParseFailureKind::AnnotationSet,
            offset: 0x3000,
        });
        for cd in &dex.class_defs {
            assert!(
                dex.is_kotlin_sealed_root(cd.class_idx).is_indeterminate(),
                "class {} returned non-Indeterminate despite planted parse_errors",
                dex.get_type_descriptor(cd.class_idx).unwrap_or("?")
            );
        }
    }

    #[test]
    fn is_kotlin_sealed_object_subclass_indeterminate_on_annotation_directory_parse_failure() {
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let mut dex = DexFile::parse(data, None).expect("parse");
        dex.parse_errors.push(ParseFailure {
            kind: ParseFailureKind::AnnotationDirectory,
            offset: 0x4000,
        });
        for cd in &dex.class_defs {
            assert!(
                dex.is_kotlin_sealed_object_subclass(cd.class_idx)
                    .is_indeterminate(),
                "class {} returned non-Indeterminate despite planted parse_errors",
                dex.get_type_descriptor(cd.class_idx).unwrap_or("?")
            );
        }
    }

    #[test]
    fn is_kotlin_sealed_class_subclass_never_silently_yes_on_class_data_parse_failure() {
        // Same shape as is_kotlin_data_class_never_silently_yes — the
        // metadata gate short-circuits before class_data is read on the
        // Java fixture, so the verdict is No. Contract: NEVER silently
        // Yes when class_data is poisoned.
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let mut dex = DexFile::parse(data, None).expect("parse");
        dex.parse_errors.push(ParseFailure {
            kind: ParseFailureKind::ClassData,
            offset: 0x5000,
        });
        for cd in &dex.class_defs {
            let verdict = dex.is_kotlin_sealed_class_subclass(cd.class_idx);
            assert!(
                !verdict.is_yes(),
                "class {} returned Yes despite planted class_data parse_errors",
                dex.get_type_descriptor(cd.class_idx).unwrap_or("?")
            );
        }
    }

    #[test]
    fn method_throws_errs_on_annotation_subtree_parse_failure() {
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let mut dex = DexFile::parse(data, None).expect("parse");
        dex.parse_errors.push(ParseFailure {
            kind: ParseFailureKind::AnnotationItem,
            offset: 0x6000,
        });
        // Pick a class with annotations_off set; if none in fixture,
        // synthesize a non-zero annotations_off — the gate fires before
        // the lookup.
        let probe_off = dex
            .class_defs
            .iter()
            .find_map(|cd| {
                if cd.annotations_off != 0 {
                    Some(cd.annotations_off)
                } else {
                    None
                }
            })
            .unwrap_or(0xDEAD_BEEF);
        let result = dex.method_throws(probe_off, MethodIdx(0));
        assert!(
            matches!(result, Err(DexError::DetectorIndeterminate { context })
                if context == "method_throws"),
            "method_throws returned {:?} despite planted AnnotationItem parse_errors",
            result
        );
    }

    #[test]
    fn find_annotated_methods_errs_on_annotation_subtree_parse_failure() {
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let mut dex = DexFile::parse(data, None).expect("parse");
        dex.parse_errors.push(ParseFailure {
            kind: ParseFailureKind::AnnotationSetRefList,
            offset: 0x7000,
        });
        let result = dex.find_annotated_methods(data, "Lkotlin/Metadata;");
        assert!(
            matches!(result, Err(DexError::DetectorIndeterminate { context })
                if context == "find_annotated_methods"),
            "find_annotated_methods returned {:?} despite planted parse_errors",
            result
        );
    }

    #[test]
    fn subsection_clean_recognizes_each_detector_kind() {
        // Foundation invariant for the helper itself: every kind we
        // poison must be reflected in subsection_clean. Guards against
        // future ParseFailureKind variants being silently omitted from
        // detectors' gate sets.
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        for kind in &[
            ParseFailureKind::AnnotationDirectory,
            ParseFailureKind::AnnotationSet,
            ParseFailureKind::AnnotationSetRefList,
            ParseFailureKind::AnnotationItem,
            ParseFailureKind::ClassData,
        ] {
            let mut dex = DexFile::parse(data, None).expect("parse");
            assert!(dex.subsection_clean(&[*kind]));
            dex.parse_errors.push(ParseFailure {
                kind: *kind,
                offset: 0x100,
            });
            assert!(
                !dex.subsection_clean(&[*kind]),
                "subsection_clean failed to detect planted {:?}",
                kind
            );
        }
    }

    /// Build a stub `DexHeader` whose `string_ids_off` / `string_ids_size`
    /// point at a hand-built two-entry string pool packed into `data`.
    /// Other fields are zeroed; `parse_strings` only reads
    /// `string_ids_off` + `string_ids_size`, so the stub suffices.
    fn header_for_two_string_pool(data: &[u8]) -> crate::header::DexHeader {
        let mut magic = [0u8; 8];
        magic[..4].copy_from_slice(b"dex\n");
        magic[4..7].copy_from_slice(b"035");
        crate::header::DexHeader {
            magic,
            checksum: 0,
            signature: [0u8; 20],
            file_size: data.len() as u32,
            header_size: 112,
            endian_tag: 0x1234_5678,
            link_size: 0,
            link_off: 0,
            map_off: 0,
            string_ids_size: 2,
            string_ids_off: 0,
            type_ids_size: 0,
            type_ids_off: 0,
            proto_ids_size: 0,
            proto_ids_off: 0,
            field_ids_size: 0,
            field_ids_off: 0,
            method_ids_size: 0,
            method_ids_off: 0,
            class_defs_size: 0,
            class_defs_off: 0,
            data_size: 0,
            data_off: 0,
        }
    }

    /// Construct a near-empty `DexFile` carrying only the strings
    /// under test, so the public `string_has_lossy_decode` accessor
    /// can be exercised. The accessor delegates to
    /// `dex.strings[idx].is_lossy()`.
    fn dex_from_strings(strings: Vec<crate::DexString>) -> DexFile {
        DexFile {
            header: header_for_two_string_pool(&[]),
            string_data_offs: vec![0; strings.len()],
            strings,
            type_descriptors: Vec::new(),
            protos: Vec::new(),
            fields: Vec::new(),
            methods: Vec::new(),
            class_defs: Vec::new(),
            annotations: BTreeMap::new(),
            type_lists: BTreeMap::new(),
            class_datas: BTreeMap::new(),
            raw_class_data_bytes: BTreeMap::new(),
            code_items: BTreeMap::new(),
            annotation_sets: BTreeMap::new(),
            annotation_set_ref_lists: BTreeMap::new(),
            annotation_items: BTreeMap::new(),
            annotation_item_widths: BTreeMap::new(),
            encoded_arrays: BTreeMap::new(),
            encoded_array_widths: BTreeMap::new(),
            method_handles: Vec::new(),
            call_site_ids: Vec::new(),
            map_entries: Vec::new(),
            debug_infos: BTreeMap::new(),
            debug_info_raw_bytes: BTreeMap::new(),
            debug_info_section_layout: Vec::new(),
            annotation_set_section_layout: Vec::new(),
            input_checksums_canonical: true,
            parse_errors: Vec::new(),
            class_def_index: Vec::new(),
        }
    }

    /// Precision gate (acceptance gate 2): a string-pool entry whose raw
    /// MUTF-8 bytes are `0xC0 0x80` (encoded NUL) round-trips correctly
    /// through `decode_mutf8` to "\0". A naive byte-diff implementation
    /// would flag this as lossy because raw≠decoded; the precise gauge
    /// must report `false`.
    #[test]
    fn string_has_lossy_decode_false_on_encoded_nul_roundtrip() {
        // Layout: [string_ids][string_data #1 (encoded NUL)][string_data #2 (single 'A')]
        // string_ids: 2 × u32 LE pointing to string_data offsets.
        let mut data = Vec::new();
        // Reserve 8 bytes for the two-entry string_ids table.
        data.extend_from_slice(&[0u8; 8]);
        // string_data #1 at offset 8: char_count=1 ULEB, raw=[C0 80], terminator.
        let off1: u32 = data.len() as u32;
        data.extend_from_slice(&[0x01, 0xC0, 0x80, 0x00]);
        // string_data #2 at offset 12: char_count=1, raw=[0x41 'A'], terminator.
        let off2: u32 = data.len() as u32;
        data.extend_from_slice(&[0x01, 0x41, 0x00]);
        // Patch string_ids[0..4] = off1, string_ids[4..8] = off2.
        data[0..4].copy_from_slice(&off1.to_le_bytes());
        data[4..8].copy_from_slice(&off2.to_le_bytes());

        let header = header_for_two_string_pool(&data);
        let (strings, _) = DexFile::parse_string_pool(&data, &header).expect("parse_string_pool ok");

        assert_eq!(strings.len(), 2, "two strings parsed");
        assert_eq!(strings[0].as_str_lossy(), "\0", "encoded NUL decodes to NUL char");
        assert_eq!(strings[1].as_str_lossy(), "A", "ASCII control");
        assert!(!strings[0].is_lossy(), "encoded NUL is byte-clean MUTF-8 — must NOT be lossy");
        assert!(!strings[1].is_lossy(), "ASCII 'A' is byte-clean — must NOT be lossy");

        // Exercise the public accessor with a constructed DexFile.
        let dex = dex_from_strings(strings);
        assert_eq!(
            dex.string_has_lossy_decode(StringIdx(0)),
            Some(false),
            "accessor must report Some(false) for encoded NUL (precision gate)"
        );
        assert_eq!(
            dex.string_has_lossy_decode(StringIdx(1)),
            Some(false),
            "accessor must report Some(false) for ASCII 'A'"
        );
    }

    /// Lossy-detection gate (acceptance gate 3): a string-pool entry with
    /// `ED A0 80 EE 81 9F` (lone high surrogate followed by a non-low-
    /// surrogate 3-byte sequence) is the empirically-dominant failure
    /// shape per closed `droidsaw-audit-mutf8-codec-bypass-fix` corpus
    /// measurement. `decode_mutf8` must return `Err` and the parser
    /// must record `marks[idx] = true`.
    #[test]
    fn string_has_lossy_decode_true_on_lone_high_surrogate() {
        let mut data = Vec::new();
        data.extend_from_slice(&[0u8; 8]);
        // string_data #1: char_count=2 ULEB, raw=[ED A0 80 EE 81 9F], terminator.
        let off1: u32 = data.len() as u32;
        data.extend_from_slice(&[0x02, 0xED, 0xA0, 0x80, 0xEE, 0x81, 0x9F, 0x00]);
        // string_data #2: char_count=1, raw='B', terminator (control case — clean).
        let off2: u32 = data.len() as u32;
        data.extend_from_slice(&[0x01, 0x42, 0x00]);
        data[0..4].copy_from_slice(&off1.to_le_bytes());
        data[4..8].copy_from_slice(&off2.to_le_bytes());

        let header = header_for_two_string_pool(&data);
        let (strings, _) = DexFile::parse_string_pool(&data, &header).expect("parse_string_pool ok");

        assert_eq!(strings.len(), 2);
        assert!(
            strings[0].is_lossy(),
            "lone high surrogate followed by non-low-surrogate must store MalformedMutf8"
        );
        assert!(!strings[1].is_lossy(), "ASCII 'B' is byte-clean — must NOT be lossy");

        let dex = dex_from_strings(strings);
        assert_eq!(
            dex.string_has_lossy_decode(StringIdx(0)),
            Some(true),
            "accessor must report Some(true) for lone-high-surrogate (precision gate)"
        );
        assert_eq!(
            dex.string_has_lossy_decode(StringIdx(1)),
            Some(false),
            "accessor must report Some(false) for the byte-clean control"
        );
    }

    /// Regression: OOB idx must return `None`, NOT silently `false`
    /// (a bool-return contract conflates "no such string" with "string
    /// exists and decoded cleanly"; the typed-return distinguishes
    /// the two cases at the boundary).
    #[test]
    fn string_has_lossy_decode_returns_none_on_oob() {
        let dex = dex_from_strings(vec![crate::DexString::from_decoded_str("A")]);
        assert_eq!(
            dex.string_has_lossy_decode(StringIdx(0)),
            Some(false),
            "idx=0 is in range; clean ASCII → Some(false)"
        );
        assert_eq!(
            dex.string_has_lossy_decode(StringIdx(1)),
            None,
            "idx=1 is out of range (only 1 string in pool) → None"
        );
        assert_eq!(
            dex.string_has_lossy_decode(StringIdx(u32::MAX)),
            None,
            "idx=u32::MAX is out of range → None"
        );
    }

    // ── validate_class_def_off — class_def offset parse-time bound ────

    #[test]
    fn class_def_off_zero_always_accepted() {
        // `off = 0` is the spec sentinel for "absent"; must pass for
        // every field tag, regardless of data_len (even data_len = 0).
        for field in [
            "interfaces_off",
            "annotations_off",
            "class_data_off",
            "static_values_off",
        ] {
            assert_eq!(validate_class_def_off(0, field, 0).unwrap(), 0);
            assert_eq!(validate_class_def_off(0, field, 1024).unwrap(), 0);
        }
    }

    #[test]
    fn class_def_off_in_bounds_accepted() {
        // off ∈ (0, data_len) is accepted for every field.
        for field in [
            "interfaces_off",
            "annotations_off",
            "class_data_off",
            "static_values_off",
        ] {
            assert_eq!(validate_class_def_off(1, field, 1024).unwrap(), 1);
            assert_eq!(validate_class_def_off(1023, field, 1024).unwrap(), 1023);
        }
    }

    #[test]
    fn class_def_off_at_or_past_data_len_rejected() {
        // off >= data_len rejected with field tag + payload preserved.
        let err =
            validate_class_def_off(1024, "interfaces_off", 1024).unwrap_err();
        assert!(
            matches!(
                err,
                DexError::ClassDefOffsetOutOfBounds {
                    field: "interfaces_off",
                    off: 1024,
                    data_len: 1024,
                }
            ),
            "got {err:?}",
        );
    }

    #[test]
    fn class_def_off_u32_max_rejected_each_field() {
        // Regression: each offset field at u32::MAX. Must
        // surface as ClassDefOffsetOutOfBounds with the correct field
        // tag for each call.
        for field in [
            "interfaces_off",
            "annotations_off",
            "class_data_off",
            "static_values_off",
        ] {
            let err = validate_class_def_off(u32::MAX, field, 4096).unwrap_err();
            match err {
                DexError::ClassDefOffsetOutOfBounds {
                    field: got_field,
                    off,
                    data_len,
                } => {
                    assert_eq!(got_field, field);
                    assert_eq!(off, u32::MAX);
                    assert_eq!(data_len, 4096);
                }
                other => panic!("expected ClassDefOffsetOutOfBounds, got {other:?}"),
            }
        }
    }

    #[test]
    fn class_def_off_field_tag_carried_verbatim() {
        // Audit-attribution invariant: the error variant carries the
        // exact field tag the caller passed in. This is the load-
        // bearing improvement over the prior deferred-Err shape —
        // operators see the parse-time origin, not the consumer site.
        let err = validate_class_def_off(99999, "class_data_off", 1024).unwrap_err();
        let DexError::ClassDefOffsetOutOfBounds {
            field, off, data_len,
        } = err else {
            panic!("expected ClassDefOffsetOutOfBounds");
        };
        assert_eq!(field, "class_data_off");
        assert_eq!(off, 99999);
        assert_eq!(data_len, 1024);
    }

    #[test]
    fn rebuild_class_def_index_first_wins_on_duplicate_class_idx() {
        // Regression test for duplicate class_idx handling.
        //
        // Without the guard: `rebuild_class_def_index` silently overwrote the
        // slot on duplicate class_idx, leaving `class_def_for_type` resolving
        // to the LAST row while linear-scan callers saw both —
        // index-vs-iteration disagreement evasion primitive.
        //
        // With the guard: first-wins per Android AOSP `DexFile::FindClassDef`,
        // and the duplicate emits a `ParseFailureKind::DuplicateClassDef`
        // entry into `parse_errors` for diag surfacing.
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let mut dex = DexFile::parse(data, None).expect("parse fixture");

        // Pick any existing class_def and plant a duplicate row carrying
        // the SAME class_idx but a distinguishable access_flags marker
        // so we can verify the slot pinned to the FIRST.
        let first = dex.class_defs.first().cloned().expect("fixture has classes");
        let first_idx = 0u32;
        let mut duplicate = first.clone();
        duplicate.access_flags = first.access_flags ^ 0x0010; // flip a bit so first != duplicate
        let dup_class_defs_idx = u32::try_from(dex.class_defs.len()).expect("≤ u32::MAX");
        dex.class_defs.push(duplicate);

        let prior_parse_errors_len = dex.parse_errors.len();
        dex.rebuild_class_def_index();

        // Index slot for the shared class_idx must point at the FIRST row.
        let slot = dex
            .class_def_index
            .get(first.class_idx.0 as usize)
            .copied()
            .flatten()
            .expect("index slot populated");
        assert_eq!(
            slot, first_idx,
            "first-wins: index must pin to the FIRST class_defs row sharing class_idx"
        );

        // class_def_for_type O(1) lookup must return the FIRST row.
        let via_index = dex
            .class_def_for_type(first.class_idx)
            .expect("class_def_for_type resolves shared class_idx");
        assert_eq!(
            via_index.access_flags, first.access_flags,
            "class_def_for_type must return the FIRST row's access_flags"
        );

        // The duplicate must have generated a ParseFailure record.
        let new_failures: Vec<_> = dex
            .parse_errors
            .iter()
            .skip(prior_parse_errors_len)
            .filter(|p| p.kind == ParseFailureKind::DuplicateClassDef)
            .collect();
        assert_eq!(
            new_failures.len(),
            1,
            "exactly one DuplicateClassDef ParseFailure must surface; got {:?}",
            dex.parse_errors
        );
        assert_eq!(
            new_failures[0].offset, dup_class_defs_idx,
            "ParseFailure.offset must carry the duplicate row's class_defs index"
        );
    }

    #[test]
    fn rebuild_class_def_index_no_parse_error_on_unique_class_idxs() {
        // Companion negative test: when class_defs has no duplicate
        // class_idx rows, rebuild_class_def_index must NOT emit any
        // DuplicateClassDef ParseFailure entries.
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let mut dex = DexFile::parse(data, None).expect("parse fixture");
        let prior_parse_errors_len = dex.parse_errors.len();
        dex.rebuild_class_def_index();
        let dup_count = dex
            .parse_errors
            .iter()
            .skip(prior_parse_errors_len)
            .filter(|p| p.kind == ParseFailureKind::DuplicateClassDef)
            .count();
        assert_eq!(
            dup_count, 0,
            "fixture has no duplicate class_idx — no DuplicateClassDef ParseFailures expected"
        );
    }

    #[test]
    fn detector_verdict_indeterminate_short_circuits_javac21_gate_polarity() {
        // Regression test for the detector-confusion primitive.
        //
        // The javac21 recognizers in `src/signatures/javac21/switch_int.rs`
        // and `src/signatures/javac21/switch_string.rs` gate on
        // `!class_has_kotlin_metadata(...).is_no()` so an Indeterminate
        // verdict (attacker-planted `ParseFailureKind::AnnotationItem`
        // on the enclosing class) short-circuits the recognizer to
        // NoMatch. Without the polarity symmetry, the sibling kotlinc19
        // recognizer suppressed Kotlin sugar on the same Indeterminate
        // input while these javac21 recognizers proceeded — same
        // Indeterminate gets opposite recognizer answers.
        //
        // This test checks the underlying polarity at the
        // DetectorVerdict layer: for every fixture class with planted
        // AnnotationItem parse failure, `class_has_kotlin_metadata`
        // returns Indeterminate AND `is_no()` returns false (so the
        // `!is_no()` gate evaluates true → recognizer bails).
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let mut dex = DexFile::parse(data, None).expect("parse");
        dex.parse_errors.push(ParseFailure {
            kind: ParseFailureKind::AnnotationItem,
            offset: 0x1000,
        });
        let mut checked = 0;
        for cd in &dex.class_defs {
            let verdict = dex.class_has_kotlin_metadata(cd.class_idx);
            assert!(
                verdict.is_indeterminate(),
                "class {} returned non-Indeterminate despite planted parse_errors",
                dex.get_type_descriptor(cd.class_idx).unwrap_or("?")
            );
            assert!(
                !verdict.is_no(),
                "Indeterminate verdict must report `is_no() == false` for the javac21 `!is_no()` gate to short-circuit to NoMatch"
            );
            checked += 1;
        }
        assert!(checked > 0, "fixture must have at least one class_def");
    }

    /// Fixture-regen test: emits the duplicate-class_idx fuzz seed at
    /// `fuzz/seeds/fuzz_parser/duplicate_class_def.dex`. Idle by
    /// default (gated on `DROIDSAW_REGEN_FUZZ_SEEDS=1`); runs as part
    /// of the seed-corpus regen workflow when a parser-shape change
    /// invalidates the seed bytes.
    ///
    /// To regenerate:
    ///   `DROIDSAW_REGEN_FUZZ_SEEDS=1 cargo test --lib -- \
    ///    regen_duplicate_class_def_fuzz_seed --nocapture`
    #[test]
    fn regen_duplicate_class_def_fuzz_seed() {
        if std::env::var("DROIDSAW_REGEN_FUZZ_SEEDS").ok().as_deref() != Some("1") {
            return;
        }
        let base = include_bytes!("../../tests/fixtures/classes_named.dex");
        let mut dex = DexFile::parse(base, None).expect("parse base fixture");
        let first = dex
            .class_defs
            .first()
            .cloned()
            .expect("base fixture has classes");
        // Plant the duplicate: same class_idx, distinguishable access
        // flags so a roundtrip-aware reader can identify which row
        // shadowed which.
        let mut duplicate = first.clone();
        duplicate.access_flags = first.access_flags ^ 0x0010;
        dex.class_defs.push(duplicate);
        dex.rebuild_class_def_index();
        // emit_dex strict-mode rejects DuplicateClassDef ParseFailures
        // (per emit_dex.rs:1927); permit-partial-ir lets the emitter
        // proceed and write both rows, which is the seed shape we want.
        let bytes = crate::emit_dex::emit_dex_with_config(
            &dex,
            &crate::emit_dex::EmitConfig {
                permit_partial_ir: true,
                ..Default::default()
            },
        )
        .expect("emit duplicate-class_def DEX");
        let out_path =
            std::path::Path::new("fuzz/seeds/fuzz_parser/duplicate_class_def.dex");
        if let Some(parent) = out_path.parent() {
            std::fs::create_dir_all(parent).expect("mkdir seed dir");
        }
        std::fs::write(out_path, &bytes).expect("write fuzz seed");
        println!(
            "wrote {} ({} bytes)",
            out_path.display(),
            bytes.len(),
        );
    }

    #[test]
    fn fuzz_seed_duplicate_class_def_round_trips_with_duplicate_record() {
        // Round-trip regression on the checked-in fuzz seed: parsing
        // it must surface a DuplicateClassDef ParseFailure record.
        // Catches the seed-decay class — if a future emit_dex change
        // mangles the seed bytes such that the duplicate is lost, the
        // seed no longer exercises the new code path and this test
        // fails.
        let data = include_bytes!("../../fuzz/seeds/fuzz_parser/duplicate_class_def.dex");
        let dex = DexFile::parse(data, None).expect("seed must parse");
        let dup_count = dex
            .parse_errors
            .iter()
            .filter(|p| p.kind == ParseFailureKind::DuplicateClassDef)
            .count();
        assert!(
            dup_count >= 1,
            "fuzz seed must carry at least one DuplicateClassDef record after parse; got {:?}",
            dex.parse_errors
        );
    }

    #[test]
    fn class_def_is_shadowed_true_on_duplicate_secondary_row() {
        // Plant a duplicate class_idx row; assert the SECOND row
        // (index 1 relative to the originally-shared first row) reports
        // shadowed = true. The first row reports shadowed = false.
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let mut dex = DexFile::parse(data, None).expect("parse fixture");
        let first = dex.class_defs.first().cloned().expect("fixture has classes");
        let dup_idx = dex.class_defs.len();
        dex.class_defs.push(first.clone());
        dex.rebuild_class_def_index();
        assert!(
            !dex.class_def_is_shadowed(0),
            "FIRST row for a shared class_idx must not be shadowed"
        );
        assert!(
            dex.class_def_is_shadowed(dup_idx),
            "SECOND row sharing class_idx must report shadowed = true"
        );
    }

    #[test]
    fn class_def_is_shadowed_false_on_unique_rows() {
        // Negative case: no duplicates planted; every row reports
        // shadowed = false.
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let dex = DexFile::parse(data, None).expect("parse fixture");
        for i in 0..dex.class_defs.len() {
            assert!(
                !dex.class_def_is_shadowed(i),
                "row {i} must not be shadowed on a fixture with unique class_idxs"
            );
        }
    }

    #[test]
    fn class_def_is_shadowed_false_on_out_of_range_index() {
        // Degenerate-case regression: OOB class_defs index returns
        // false (the caller already missed `.get()`-bounded lookup;
        // further shadow gating is moot).
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let dex = DexFile::parse(data, None).expect("parse fixture");
        let oob = dex.class_defs.len().saturating_add(1000);
        assert!(
            !dex.class_def_is_shadowed(oob),
            "out-of-range class_defs index must return false (no panic)"
        );
    }

    #[test]
    fn classes_to_decompile_skips_shadowed_rows() {
        // Regression test for duplicate class_idx shadowing.
        // Without the guard, classes_to_decompile surfaced both rows for a
        // duplicate class_idx (iteration saw all rows; index resolved to FIRST).
        // With the guard, the shadow gate drops every row that's not the first
        // encounter for its class_idx.
        let data = include_bytes!("../../tests/fixtures/classes_named.dex");
        let mut dex = DexFile::parse(data, None).expect("parse fixture");
        let first = dex.class_defs.first().cloned().expect("fixture has classes");
        let baseline_count = crate::classes::classes_to_decompile(&dex).count();
        dex.class_defs.push(first.clone());
        dex.rebuild_class_def_index();
        let post_dup_count = crate::classes::classes_to_decompile(&dex).count();
        assert_eq!(
            baseline_count, post_dup_count,
            "classes_to_decompile must yield the same count after planting a duplicate-class_idx row (shadow gate filters it); baseline={baseline_count}, post-dup={post_dup_count}"
        );
    }
}
