//! In-tree D8-desugar ratchet — the EMPIRICAL anchor for the
//! structural claim that `Lj$/*` classes are NOT R8 outlines.
//!
//! Consumes the artifacts at
//! `tests/fixtures/r8/d8_desugar/artifacts/{classes.dex,mapping.txt}`
//! produced by `scripts/regen-d8-desugar-fixture.sh`. The Kotlin source
//! under `tests/fixtures/r8/d8_desugar/source/Main.kt` is engineered to
//! invoke enough `java.time` + `java.util.stream` API surface that D8
//! (at `--min-api 24`) emits a population of `Lj$/time/` and
//! `Lj$/util/stream/` desugared-library backport classes into the DEX.
//! The Kotlin source ALSO contains a 30-caller `OutlineLike` group
//! whose body is structurally identical to the BlockOutline fixture's
//! GroupAInt — this gives the recogniser at least one non-j$ outline
//! target to fire on (positive control).
//!
//! Sibling to `r8_custom_fixture_ratchet.rs` but with the assertion
//! INVERTED: the BlockOutline fixture asserts the recogniser fires
//! exactly on the (class, method) pairs the mapping says are
//! outlined. This fixture asserts the recogniser does NOT fire on any
//! `Lj$/*` class.
//!
//! Structural claim being anchored (source citations in
//! `tests/fixtures/r8/d8_desugar/README.md`):
//!
//! - `LibraryDesugaredChecker.java:81` — the gate that prevents
//!   R8-generated synthetics from landing in the desugared-library
//!   namespace.
//! - `SyntheticNaming.java:104-139` — the synthetic-kind enumeration;
//!   no variant emits into `j$/`.
//! - `OutlinerImpl.java:1593-1595` — the outliner's eligibility check
//!   excludes class members that come from desugared-library inputs.
//!
//! # SKIP path
//!
//! The artifacts are NOT generated by this commit — the dev box
//! does not carry the Android SDK + desugared-library config. The
//! regen pipeline requires a machine with the full Android build
//! toolchain. Until artifacts land, the test SKIPs cleanly with a
//! message pointing at the regen script.

mod common;

use std::path::PathBuf;

use common::r8_canonical_marker::{descriptor_to_mapping_key, parse_block_outlined_marker};
use common::r8_mapping_outline::OutlineSet;

const FIXTURE_REL: &str = "tests/fixtures/r8/d8_desugar/artifacts";
const SMOKE_TEST_STACK_BYTES: usize = 16 * 1024 * 1024;

/// Lower bound on `j$.*` class records the mapping must contain. The
/// fixture invokes `java.time` + `java.util.stream` surface
/// engineered to make D8 desugar into the dominant
/// `j$.time.*` + `j$.util.stream.*` families. A floor of 3 catches
/// "D8 desugaring did not fire" without being so tight that a minor
/// L8 config change breaks the test.
const MIN_EXPECTED_JDOLLAR_CLASSES: usize = 3;

fn fixture_path(name: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join(FIXTURE_REL)
        .join(name)
}

#[test]
fn r8_d8_desugar_fixture_recogniser_does_not_fire_on_jdollar() {
    let handle = std::thread::Builder::new()
        .name("r8_d8_desugar_fixture_worker".into())
        .stack_size(SMOKE_TEST_STACK_BYTES)
        .spawn(fixture_main)
        .expect("spawn stack-sized worker thread");
    if let Err(e) = handle.join() {
        std::panic::resume_unwind(e);
    }
}

fn fixture_main() {
    let dex_path = fixture_path("classes.dex");
    let mapping_path = fixture_path("mapping.txt");

    if !dex_path.is_file() || !mapping_path.is_file() {
        eprintln!(
            "SKIP: in-tree D8-desugar fixture artifacts not present at {}. \
             Run tests/fixtures/r8/d8_desugar/scripts/regen-d8-desugar-fixture.sh \
             to generate classes.dex + mapping.txt from the engineered Kotlin \
             source. Requires Android SDK build-tools (r8 / d8) + kotlinc + \
             the desugared-library config (DESUGARED_LIB_JSON + DESUGARED_LIB_JAR).",
            dex_path.parent().map(|p| p.display().to_string()).unwrap_or_default(),
        );
        return;
    }

    let mapping_text = match std::fs::read_to_string(&mapping_path) {
        Ok(s) => s,
        Err(e) => panic!("read {}: {e}", mapping_path.display()),
    };

    // ── Assertion 2 ── j$.* class records present in mapping.
    //
    // Walk class records (unindented lines ending in `:` with a
    // ` -> ` arrow) and partition by whether the LHS (original-name
    // half) starts with `j$.`. The mapping uses source-form names
    // (dot-separated, no `L...;` framing).
    let class_records = parse_class_records(&mapping_text);
    let jdollar_classes: Vec<&ClassRecord> = class_records
        .iter()
        .filter(|r| r.original.starts_with("j$."))
        .collect();
    assert!(
        jdollar_classes.len() >= MIN_EXPECTED_JDOLLAR_CLASSES,
        "mapping.txt has {} j$.* class records, expected >= {}. \
         D8's desugaring step did not fire at the expected density. \
         Check --min-api setting (must be 24) and the DESUGARED_LIB_JSON \
         path. Without j$.* classes the fixture has no anchor to test.",
        jdollar_classes.len(),
        MIN_EXPECTED_JDOLLAR_CLASSES,
    );

    // ── Assertion 3 ── No j$.* class carries com.android.tools.r8.outline.
    //
    // Walk the mapping, tracking the current class (by original
    // name) and reporting any `com.android.tools.r8.outline`
    // annotation whose owning class is in the j$.* namespace.
    let offenders = collect_jdollar_outline_offenders(&mapping_text);
    assert!(
        offenders.is_empty(),
        "mapping.txt has com.android.tools.r8.outline annotation(s) on \
         {} j$.* class(es), violating the structural claim that R8 does \
         NOT emit outlines into the desugared-library namespace. \
         (See LibraryDesugaredChecker.java:81 + OutlinerImpl.java:1593-1595.) \
         Offending classes:\n  {}",
        offenders.len(),
        offenders.iter().take(10).cloned().collect::<Vec<_>>().join("\n  "),
    );

    // OutlineSet for cross-checking the recogniser markers against
    // the mapping ground truth.
    let outlines = match OutlineSet::from_file(&mapping_path) {
        Ok(s) => s,
        Err(e) => panic!(
            "in-tree fixture mapping.txt failed to parse: {e} (path: {}). \
             Re-run the regen script.",
            mapping_path.display(),
        ),
    };
    let outline_count = outlines.outlined_count();

    // Walk the DEX through the recogniser, recording every marker.
    let data = std::fs::read(&dex_path)
        .unwrap_or_else(|e| panic!("read {}: {e}", dex_path.display()));
    let dex = droidsaw_dex::parser::DexFile::parse(&data, None)
        .unwrap_or_else(|e| panic!("parse {}: {e:?}", dex_path.display()));
    let census = droidsaw_dex::r8_inversion::build_trampoline_census(&dex);

    let mut jdollar_helper_markers: Vec<(String, String)> = Vec::new();
    let mut non_jdollar_helper_markers: Vec<(String, String)> = Vec::new();
    let mut total_markers = 0usize;
    let mut total_classes = 0usize;

    for class_def in &dex.class_defs {
        if class_def.class_data_off == 0 {
            continue;
        }
        total_classes = total_classes.saturating_add(1);
        let out = droidsaw_dex::classes::decompile_class_with_census(
            &dex, &data, class_def, &census,
        );
        for line in out.lines() {
            let Some(marker) = parse_block_outlined_marker(line) else {
                continue;
            };
            total_markers = total_markers.saturating_add(1);
            // helper_class is a DEX descriptor like "Lj$/time/LocalDate;"
            // or "La;" — split by whether the descriptor inner starts
            // with `j$/`.
            let key = descriptor_to_mapping_key(marker.helper_class);
            if marker.helper_class.starts_with("Lj$/") {
                jdollar_helper_markers.push((key, marker.helper_method.to_string()));
            } else {
                non_jdollar_helper_markers.push((key, marker.helper_method.to_string()));
            }
        }
    }

    eprintln!(
        "D8-DESUGAR RATCHET: {total_classes} classes walked, {total_markers} markers fired",
    );
    eprintln!(
        "  mapping: {} j$.* class records, {} com.android.tools.r8.outline annotations",
        jdollar_classes.len(),
        outline_count,
    );
    eprintln!(
        "  recogniser fired on {} j$/* helpers (must be 0), {} non-j$ helpers (positive control)",
        jdollar_helper_markers.len(),
        non_jdollar_helper_markers.len(),
    );

    // ── Assertion 4 ── Recogniser fires ZERO markers naming a
    //                   helper-class in the Lj$/ namespace.
    assert!(
        jdollar_helper_markers.is_empty(),
        "recogniser fired {} marker(s) whose helper-class descriptor \
         starts with Lj$/. The structural claim is that R8 does NOT \
         emit outlines into the desugared-library namespace — if this \
         assertion fires, EITHER R8 has changed and started outlining \
         into j$/* (a real upstream regression worth surfacing), OR \
         the recogniser is mis-classifying a D8 backport as an outline \
         (a recogniser bug). First offenders:\n  {}",
        jdollar_helper_markers.len(),
        jdollar_helper_markers
            .iter()
            .take(5)
            .map(|(c, m)| format!("{c}.{m}"))
            .collect::<Vec<_>>()
            .join("\n  "),
    );

    // ── Assertion 5 ── Recogniser fires on at least one non-j$ helper.
    //
    // Positive control: if the recogniser fires nowhere in the DEX,
    // the test is not actually exercising it and Assertion 4 is
    // vacuously satisfied. The OutlineLike group in source/Main.kt
    // is engineered to produce at least one outline helper in the
    // non-j$ namespace.
    assert!(
        !non_jdollar_helper_markers.is_empty(),
        "recogniser fired ZERO markers on non-j$ classes despite the \
         mapping having {outline_count} com.android.tools.r8.outline \
         annotations. Either the OutlineLike group in source/Main.kt \
         no longer satisfies R8's outliner predicate, OR the recogniser \
         has regressed. Without a positive control, Assertion 4 is \
         vacuous — fix this first before debugging the j$ check."
    );
}

// ── Inline mapping helpers ──
//
// The shared OutlineSet parser doesn't expose original-class names
// (it canonicalises to obfuscated form). We need the original (LHS)
// half to test the j$/* claim, so do a minimal parse inline.

struct ClassRecord {
    original: String,
    #[allow(dead_code)]
    obfuscated: String,
}

fn parse_class_records(text: &str) -> Vec<ClassRecord> {
    let mut out: Vec<ClassRecord> = Vec::new();
    for raw in text.lines() {
        if raw.is_empty() {
            continue;
        }
        let first = raw.as_bytes()[0];
        if first == b' ' || first == b'\t' || first == b'#' {
            continue;
        }
        let Some(stripped) = raw.strip_suffix(':') else {
            continue;
        };
        let Some((orig, obf)) = stripped.split_once(" -> ") else {
            continue;
        };
        if orig.is_empty() || obf.is_empty() {
            continue;
        }
        out.push(ClassRecord {
            original: orig.to_string(),
            obfuscated: obf.to_string(),
        });
    }
    out
}

/// Walk mapping.txt, tracking the current class, and collect the
/// original (LHS) class name of any j$.* class whose body contains a
/// `com.android.tools.r8.outline` annotation. Returns one entry per
/// offending class (sorted, deduped).
fn collect_jdollar_outline_offenders(text: &str) -> Vec<String> {
    const OUTLINE_ID_ANCHOR: &str = "\"id\":\"com.android.tools.r8.outline\"";
    let mut offenders: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
    let mut current_class: Option<String> = None;
    for raw in text.lines() {
        if raw.is_empty() {
            continue;
        }
        let first = raw.as_bytes()[0];
        // Class record line: unindented, no leading `#`.
        if first != b' ' && first != b'\t' && first != b'#' {
            if let Some(stripped) = raw.strip_suffix(':') {
                if let Some((orig, _obf)) = stripped.split_once(" -> ") {
                    if !orig.is_empty() {
                        current_class = Some(orig.to_string());
                        continue;
                    }
                }
            }
            // Unindented non-class-record line — clear current class
            // so a stray annotation outside a class record can't be
            // mis-attributed.
            current_class = None;
            continue;
        }
        // Annotation comment line.
        if first == b'#' || raw.trim_start().starts_with('#') {
            if !raw.contains(OUTLINE_ID_ANCHOR) {
                continue;
            }
            if let Some(cls) = current_class.as_ref() {
                if cls.starts_with("j$.") {
                    offenders.insert(cls.clone());
                }
            }
        }
    }
    offenders.into_iter().collect()
}
