use std::{
    env,
    path::{Path, PathBuf},
    process::Command,
    time::Duration,
};

use ghidra::{
    AddressRange, AnalysisMode, AnalysisOptions, DecompileOptions, DecompileStatus, Error,
    FunctionDescriptor, Ghidra, LaunchOptions, ProgramLoadOptions, ProgramOpenOptions, ProgramPath,
    SymbolKind,
};
use tempfile::TempDir;

#[test]
fn project_program_lifecycle_exposes_typed_function_data_when_requested() -> anyhow::Result<()> {
    if !live_ghidra_requested() {
        return Ok(());
    }

    let temp = TempDir::new()?;
    let binary = compile_tiny_codec(temp.path())?;
    let ghidra = Ghidra::start(LaunchOptions::default())?;
    let project_location = temp.path().join("ghidra-projects").join("lifecycle");

    {
        let project = ghidra.open_or_create_project(&project_location, "lifecycle")?;
        let program = project.open_or_import_program(&binary)?;
        program.analyze()?;
        let functions = program.functions()?;
        let checksum = functions
            .iter()
            .find(|function| function.name == "checksum8")
            .expect("checksum8 function exists");
        let decompiled = program.decompile_function(checksum, 60)?;

        assert_eq!(program.name()?, "tiny_codec");
        assert_eq!(checksum.name, "checksum8");
        assert_eq!(decompiled.status, DecompileStatus::Completed);
        assert!(program.basic_block_count(checksum)? > 0);
        assert!(program.instruction_count(checksum)? > 0);
        program.save()?;
    }

    let project = ghidra.open_or_create_project(&project_location, "lifecycle")?;
    let program = project.open_or_import_program(&binary)?;
    assert!(
        program
            .functions()?
            .iter()
            .any(|function| function.name == "parse_header")
    );
    Ok(())
}

#[test]
fn committed_transaction_persists_function_plate_comment_when_requested() -> anyhow::Result<()> {
    if !live_ghidra_requested() {
        return Ok(());
    }

    let temp = TempDir::new()?;
    let binary = compile_tiny_codec(temp.path())?;
    let ghidra = Ghidra::start(LaunchOptions::default())?;
    let project_location = temp.path().join("ghidra-projects").join("commit");

    {
        let project = ghidra.open_or_create_project(&project_location, "commit")?;
        let program = project.open_or_import_program(&binary)?;
        program.analyze()?;
        let checksum = checksum_function(&program)?;
        let transaction = program.start_transaction("set checksum8 comment")?;
        transaction.set_function_plate_comment(&checksum, "committed checksum comment")?;
        transaction.commit()?;
        assert_eq!(
            program.function_plate_comment(&checksum)?.as_deref(),
            Some("committed checksum comment")
        );
        program.save()?;
    }

    let project = ghidra.open_or_create_project(&project_location, "commit")?;
    let program = project.open_or_import_program(&binary)?;
    let checksum = checksum_function(&program)?;
    assert_eq!(
        program.function_plate_comment(&checksum)?.as_deref(),
        Some("committed checksum comment")
    );
    Ok(())
}

#[test]
fn dropped_transaction_rolls_back_function_plate_comment_when_requested() -> anyhow::Result<()> {
    if !live_ghidra_requested() {
        return Ok(());
    }

    let temp = TempDir::new()?;
    let binary = compile_tiny_codec(temp.path())?;
    let ghidra = Ghidra::start(LaunchOptions::default())?;
    let project = ghidra.open_or_create_project(
        temp.path().join("ghidra-projects").join("drop-rollback"),
        "drop-rollback",
    )?;
    let program = project.open_or_import_program(&binary)?;
    program.analyze()?;
    let checksum = checksum_function(&program)?;
    let original = program.function_plate_comment(&checksum)?;

    {
        let transaction = program.start_transaction("rolled back checksum8 comment")?;
        transaction.set_function_plate_comment(&checksum, "dropped transaction comment")?;
    }

    assert_eq!(program.function_plate_comment(&checksum)?, original);
    Ok(())
}

#[test]
fn explicit_transaction_rollback_discards_function_plate_comment_when_requested()
-> anyhow::Result<()> {
    if !live_ghidra_requested() {
        return Ok(());
    }

    let temp = TempDir::new()?;
    let binary = compile_tiny_codec(temp.path())?;
    let ghidra = Ghidra::start(LaunchOptions::default())?;
    let project = ghidra.open_or_create_project(
        temp.path()
            .join("ghidra-projects")
            .join("explicit-rollback"),
        "explicit-rollback",
    )?;
    let program = project.open_or_import_program(&binary)?;
    program.analyze()?;
    let checksum = checksum_function(&program)?;
    let original = program.function_plate_comment(&checksum)?;

    let transaction = program.start_transaction("explicit rollback checksum8 comment")?;
    transaction.set_function_plate_comment(&checksum, "rolled back transaction comment")?;
    transaction.rollback()?;

    assert_eq!(program.function_plate_comment(&checksum)?, original);
    Ok(())
}

#[test]
fn program_navigation_returns_typed_model_when_requested() -> anyhow::Result<()> {
    if !live_ghidra_requested() {
        return Ok(());
    }

    let temp = TempDir::new()?;
    let binary = compile_tiny_codec(temp.path())?;
    let ghidra = Ghidra::start(LaunchOptions::default())?;
    let project = ghidra.open_or_create_project(
        temp.path().join("ghidra-projects").join("navigation"),
        "navigation",
    )?;
    let program = project.open_or_import_program(&binary)?;
    program.analyze()?;
    let checksum = checksum_function(&program)?;

    let parsed = program.parse_address(checksum.entry.as_str())?;
    assert_eq!(parsed, checksum.entry);
    assert_eq!(
        program.function_at(&checksum.entry)?.as_ref(),
        Some(&checksum)
    );
    assert_eq!(
        program.function_containing(&checksum.entry)?.as_ref(),
        Some(&checksum)
    );

    assert!(program.symbols()?.iter().any(|symbol| {
        symbol.name == "checksum8"
            && symbol.address == checksum.entry
            && symbol.kind == SymbolKind::Function
    }));
    assert!(
        program
            .symbols_at(&checksum.entry)?
            .iter()
            .any(|symbol| symbol.name == "checksum8")
    );
    assert!(
        program
            .find_symbols("checksum8", true)?
            .iter()
            .any(|symbol| symbol.address == checksum.entry)
    );
    assert!(
        program
            .references_to(&checksum.entry)?
            .iter()
            .any(|reference| reference.call)
    );
    let metadata = program.metadata()?;
    assert!(
        metadata
            .functions
            .iter()
            .any(|function| function.name == "checksum8")
    );
    assert!(
        metadata
            .symbols
            .iter()
            .any(|symbol| symbol.name == "checksum8")
    );
    assert!(!metadata.types.is_empty());
    assert!(program.memory_blocks()?.iter().any(|block| {
        block.execute
            && block.range.start.space == checksum.entry.space
            && block.range.start.offset <= checksum.entry.offset
            && checksum.entry.offset <= block.range.end.offset
    }));
    Ok(())
}

#[test]
fn program_decompiler_returns_direct_results_when_requested() -> anyhow::Result<()> {
    if !live_ghidra_requested() {
        return Ok(());
    }

    let temp = TempDir::new()?;
    let binary = compile_tiny_codec(temp.path())?;
    let ghidra = Ghidra::start(LaunchOptions::default())?;
    let project = ghidra.open_or_create_project(
        temp.path().join("ghidra-projects").join("decompiler"),
        "decompiler",
    )?;
    let program = project.open_or_import_program(&binary)?;
    program.analyze()?;
    let checksum = checksum_function(&program)?;

    let decompiler = program.open_decompiler()?;
    let result = decompiler.decompile(&checksum, 60)?;
    assert_eq!(result.status, DecompileStatus::Completed);
    assert!(result.c.as_deref().is_some_and(|c| c.contains("checksum8")));
    assert!(
        result
            .signature
            .as_deref()
            .is_some_and(|signature| signature.contains("checksum8"))
    );
    assert!(result.prototype.is_some());
    assert!(
        result
            .pcode
            .as_ref()
            .is_some_and(|pcode| pcode.op_count > 0)
    );
    assert!(
        result
            .high_pcode
            .as_ref()
            .is_some_and(|graph| !graph.ops.is_empty() && !graph.blocks.is_empty())
    );
    assert!(
        result
            .data_flow
            .as_ref()
            .is_some_and(|graph| !graph.ops.is_empty()
                && !graph.varnodes.is_empty()
                && !graph.variables.is_empty()
                && !graph.edges.is_empty())
    );

    let one_shot = program.decompile_function(&checksum, 60)?;
    assert_eq!(one_shot.status, DecompileStatus::Completed);
    assert_eq!(one_shot.signature, result.signature);

    let option_based = program.decompile_function_with_options(
        &checksum,
        DecompileOptions::new(60).with_monitor_timeout(Duration::from_secs(10))?,
    )?;
    assert_eq!(option_based.status, DecompileStatus::Completed);
    assert_eq!(option_based.signature, result.signature);
    Ok(())
}

#[test]
fn program_listing_returns_typed_instructions_and_data_when_requested() -> anyhow::Result<()> {
    if !live_ghidra_requested() {
        return Ok(());
    }

    let temp = TempDir::new()?;
    let binary = compile_tiny_codec(temp.path())?;
    let ghidra = Ghidra::start(LaunchOptions::default())?;
    let project = ghidra.open_or_create_project(
        temp.path().join("ghidra-projects").join("listing"),
        "listing",
    )?;
    let program = project.open_or_import_program(&binary)?;
    program.analyze()?;
    let checksum = checksum_function(&program)?;

    let instructions = program.instructions_for_function(&checksum)?;
    let first = instructions.first().expect("checksum8 has instructions");
    let last = instructions.last().expect("checksum8 has instructions");
    assert_eq!(first.address, checksum.entry);
    assert_eq!(first.length as usize, first.bytes.len());
    assert!(
        instructions
            .iter()
            .any(|instruction| !instruction.pcode.is_empty())
    );
    assert!(
        instructions
            .iter()
            .any(|instruction| !instruction.operands.is_empty())
    );
    assert_eq!(
        program.instruction_at(&checksum.entry)?.as_ref(),
        Some(first)
    );

    let ranged = program.instructions_in_range(&AddressRange {
        start: first.address.clone(),
        end: last.address.clone(),
    })?;
    assert!(
        ranged
            .iter()
            .any(|instruction| instruction.address == first.address)
    );

    let graph = program.control_flow_graph(&checksum)?;
    assert!(!graph.blocks.is_empty());
    assert!(graph.blocks.iter().any(|block| {
        block
            .instruction_addresses
            .iter()
            .any(|address| address == &checksum.entry)
    }));
    let single_graph = program.control_flow_graphs(std::slice::from_ref(&checksum))?;
    assert_eq!(single_graph.len(), 1);
    assert_eq!(single_graph[0].function, checksum);
    assert_eq!(single_graph[0].graph, graph);

    let functions = program.functions()?;
    let graphs = program.control_flow_graphs(&functions)?;
    assert_eq!(graphs.len(), functions.len());
    for (function, graph) in functions.iter().zip(graphs.iter()) {
        assert_eq!(&graph.function, function);
    }
    assert!(program.control_flow_graphs(&[])?.is_empty());

    let call_graph = program.call_graph()?;
    assert!(call_graph.nodes.iter().any(|node| node.name == "checksum8"));
    assert!(call_graph.nodes.iter().any(|node| node.name == "main"));
    assert!(call_graph.edges.iter().any(|edge| {
        edge.source_id.contains("main") || edge.destination_id.contains(&checksum.entry.to_string())
    }));

    let main = program
        .functions()?
        .into_iter()
        .find(|function| function.name == "main")
        .expect("main function exists");
    let data_refs = program.data_refs(&main)?;
    let data_ref = data_refs.first().expect("main has fixture data references");
    let data = program
        .data_containing(data_ref)?
        .expect("data exists for fixture data reference");
    assert!(data.length > 0);
    assert!(!data.display.is_empty());
    Ok(())
}

#[test]
fn java_exceptions_are_structured_when_requested() -> anyhow::Result<()> {
    if !live_ghidra_requested() {
        return Ok(());
    }

    let temp = TempDir::new()?;
    let binary = compile_tiny_codec(temp.path())?;
    let ghidra = Ghidra::start(LaunchOptions::default())?;
    let project = ghidra.open_or_create_project(
        temp.path().join("ghidra-projects").join("structured-error"),
        "structured-error",
    )?;
    let program = project.open_or_import_program(&binary)?;
    let error = program
        .parse_address("not-an-address")
        .expect_err("invalid address should fail");

    let Error::JavaException {
        class_name,
        message,
        stack_trace,
        ..
    } = error
    else {
        panic!("expected structured Java exception");
    };
    assert_eq!(class_name, "java.lang.IllegalArgumentException");
    assert!(message.contains("invalid address not-an-address"));
    assert!(stack_trace.contains("ProgramModel.parseAddress"));
    Ok(())
}

#[test]
fn typed_lifecycle_collects_function_summaries_when_requested() -> anyhow::Result<()> {
    if !live_ghidra_requested() {
        return Ok(());
    }

    let temp = TempDir::new()?;
    let binary = compile_tiny_codec(temp.path())?;
    let ghidra = Ghidra::start(LaunchOptions::default())?;
    let project = ghidra.open_or_create_project(
        temp.path().join("ghidra-projects").join("extract"),
        "extract",
    )?;
    let program = project
        .open_or_import_program_with_options(ProgramLoadOptions::new(binary)?)?
        .program;
    program.analyze_with_options(AnalysisOptions::if_needed())?;
    let functions = program.functions()?;
    let checksum = functions
        .iter()
        .find(|function| function.name == "checksum8")
        .expect("checksum8 function exists");
    let single = program.function_summary(checksum)?;

    assert!(
        functions
            .iter()
            .any(|function| function.name == "checksum8")
    );
    assert!(
        functions
            .iter()
            .any(|function| function.name == "parse_header")
    );
    assert_eq!(&single.function, checksum);
    assert!(single.instruction_count > 0);
    assert_eq!(
        single.basic_block_count,
        program.basic_block_count(checksum)?
    );
    assert_eq!(
        single.instruction_count,
        program.instruction_count(checksum)?
    );
    assert_eq!(single.callers, program.callers(checksum)?);
    assert_eq!(single.callees, program.callees(checksum)?);
    assert_eq!(single.strings, program.strings(checksum)?);
    assert_eq!(single.constants, program.constants(checksum)?);
    assert_eq!(single.data_refs, program.data_refs(checksum)?);
    assert_eq!(single.imports, program.imports(checksum)?);
    assert_eq!(single.exports, program.exports(checksum)?);
    assert_eq!(single.source_map, program.source_map(checksum)?);

    let summaries = program.function_summaries(&functions)?;
    assert_eq!(summaries.len(), functions.len());
    for (function, summary) in functions.iter().zip(summaries.iter()) {
        assert_eq!(&summary.function, function);
    }
    assert_eq!(
        summaries
            .iter()
            .find(|summary| &summary.function == checksum)
            .expect("checksum8 summary exists"),
        &single
    );
    assert!(program.function_summaries(&[])?.is_empty());
    program.save()?;
    Ok(())
}

#[test]
fn program_loading_options_control_project_path_and_reuse_when_requested() -> anyhow::Result<()> {
    if !live_ghidra_requested() {
        return Ok(());
    }

    let temp = TempDir::new()?;
    let binary = compile_tiny_codec(temp.path())?;
    let ghidra = Ghidra::start(LaunchOptions::default())?;
    let project = ghidra.open_or_create_project(
        temp.path().join("ghidra-projects").join("load-options"),
        "load-options",
    )?;
    let path = ProgramPath::new("/firmware/samples", "custom-tiny")?;
    let options = ProgramLoadOptions::new(&binary)?
        .with_path(path.clone())
        .with_timeout(Duration::from_secs(10))?;

    let loaded = project.open_or_import_program_with_options(options.clone())?;
    assert!(!loaded.info.opened_existing);
    assert_eq!(loaded.info.project_path, "/firmware/samples/custom-tiny");
    assert_eq!(loaded.info.folder, "/firmware/samples");
    assert_eq!(loaded.info.name, "custom-tiny");
    assert_eq!(loaded.info.monitor.timeout_seconds, Some(10));
    assert!(!loaded.info.monitor.cancelled);
    assert_eq!(loaded.program.name()?, "tiny_codec");
    loaded.program.save()?;
    loaded.program.close()?;

    let reopened = project.open_or_import_program_with_options(options)?;
    assert!(reopened.info.opened_existing);
    assert_eq!(reopened.info.name, "custom-tiny");
    assert_eq!(reopened.info.monitor.timeout_seconds, Some(10));
    assert!(!reopened.info.monitor.cancelled);
    assert_eq!(reopened.program.name()?, "tiny_codec");
    reopened.program.close()?;

    let opened = project.open_program(ProgramOpenOptions::new(path))?;
    assert_eq!(opened.name()?, "tiny_codec");
    Ok(())
}

#[test]
fn analysis_modes_return_typed_reports_when_requested() -> anyhow::Result<()> {
    if !live_ghidra_requested() {
        return Ok(());
    }

    let temp = TempDir::new()?;
    let binary = compile_tiny_codec(temp.path())?;
    let ghidra = Ghidra::start(LaunchOptions::default())?;
    let project = ghidra.open_or_create_project(
        temp.path().join("ghidra-projects").join("analysis-options"),
        "analysis-options",
    )?;
    let program = project.open_or_import_program(&binary)?;

    let skipped = program.analyze_with_options(AnalysisOptions::skip())?;
    assert_eq!(skipped.mode, AnalysisMode::Skip);
    assert!(!skipped.analyzed);
    assert_eq!(skipped.monitor.timeout_seconds, None);
    assert!(!skipped.monitor.cancelled);

    let if_needed = program.analyze_with_options(AnalysisOptions::if_needed())?;
    assert_eq!(if_needed.mode, AnalysisMode::IfNeeded);
    assert_eq!(if_needed.monitor.timeout_seconds, None);

    let force = program
        .analyze_with_options(AnalysisOptions::force().with_timeout(Duration::from_secs(10))?)?;
    assert_eq!(force.mode, AnalysisMode::Force);
    assert!(force.analyzed);
    assert_eq!(force.monitor.timeout_seconds, Some(10));
    assert!(!force.monitor.cancelled);
    Ok(())
}

fn checksum_function(program: &ghidra::Program<'_>) -> anyhow::Result<FunctionDescriptor> {
    Ok(program
        .functions()?
        .into_iter()
        .find(|function| function.name == "checksum8")
        .expect("checksum8 function exists"))
}

fn live_ghidra_requested() -> bool {
    env::var("GHIDRA_RUN_LIVE_TESTS").ok().as_deref() == Some("1")
}

fn compile_tiny_codec(temp: &Path) -> anyhow::Result<PathBuf> {
    let binary = temp.join("tiny_codec");
    let compiler = env::var_os("CC").unwrap_or_else(|| "cc".into());
    let status = Command::new(compiler)
        .arg("-std=c11")
        .arg("-Wall")
        .arg("-Wextra")
        .arg("-Og")
        .arg("-g")
        .arg("-fno-inline")
        .arg("-o")
        .arg(&binary)
        .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tiny_codec.c"))
        .status()?;
    if !status.success() {
        anyhow::bail!("C compiler failed with status {status}");
    }
    Ok(binary)
}
