use anyhow::{Context, Result};
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PlotFunctionInput {
    pub function_name: String,
    pub function_label: String,
    pub target: String,
    pub iterations: u32,
    pub warmup: u32,
    pub devices: Vec<PlotDeviceSamples>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PlotDeviceSamples {
    pub device_name: String,
    pub os_version: String,
    pub samples_ns: Vec<u64>,
}

#[test]
fn extract_function_plot_inputs_reads_fixture_samples() {
    let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ci-artifact-root");

    let plots = extract_function_plot_inputs_from_results_dir(&root).expect("extract plot inputs");

    let alpha = plots
        .iter()
        .find(|plot| plot.function_name == "bench_alpha")
        .expect("bench_alpha plot");

    assert_eq!(alpha.devices.len(), 1);
    assert_eq!(alpha.devices[0].device_name, "Google Pixel 8");
    assert_eq!(
        alpha.devices[0].samples_ns,
        vec![
            95_000_000,
            98_000_000,
            100_000_000,
            120_000_000,
            123_000_000
        ]
    );
}

#[test]
fn extract_function_plot_inputs_walks_nested_files_without_duplicates() {
    let root = tempfile::tempdir().expect("tempdir");
    let root_summary = root.path().join("summary.json");
    let nested_dir = root.path().join("android").join("bench_beta");
    fs::create_dir_all(&nested_dir).expect("create nested dir");

    write_json(
        &root_summary,
        serde_json::json!({
            "summary": {
                "generated_at": "2026-03-25T00:00:00Z",
                "generated_at_unix": 1_000_000_000_u64,
                "target": "android",
                "function": "bench_alpha",
                "iterations": 3,
                "warmup": 1,
                "devices": ["Google Pixel 8-14.0"],
                "device_summaries": [{
                    "device": "Google Pixel 8-14.0",
                    "benchmarks": [{
                        "function": "bench_alpha",
                        "samples": 3,
                        "mean_ns": 100_u64,
                        "median_ns": 100_u64,
                        "p95_ns": 100_u64,
                        "min_ns": 100_u64,
                        "max_ns": 100_u64
                    }]
                }]
            },
            "benchmark_results": {
                "Google Pixel 8-14.0": [{
                    "function": "bench_alpha",
                    "samples": [100_u64, 200_u64, 300_u64]
                }]
            }
        }),
    );

    write_json(
        &nested_dir.join("summary.json"),
        serde_json::json!({
            "summary": {
                "generated_at": "2026-03-25T00:00:00Z",
                "generated_at_unix": 1_000_000_001_u64,
                "target": "android",
                "function": "bench_beta",
                "iterations": 3,
                "warmup": 1,
                "devices": ["Google Pixel 8-14.0"],
                "device_summaries": [{
                    "device": "Google Pixel 8-14.0",
                    "benchmarks": [{
                        "function": "bench_beta",
                        "samples": 3,
                        "mean_ns": 400_u64,
                        "median_ns": 400_u64,
                        "p95_ns": 400_u64,
                        "min_ns": 400_u64,
                        "max_ns": 400_u64
                    }]
                }]
            },
            "benchmark_results": {
                "Google Pixel 8-14.0": [{
                    "function": "bench_alpha",
                    "samples": [100_u64, 200_u64, 300_u64]
                }, {
                    "function": "bench_beta",
                    "samples": [400_u64, 500_u64, 600_u64]
                }]
            }
        }),
    );

    let plots =
        extract_function_plot_inputs_from_results_dir(root.path()).expect("extract plot inputs");

    let alpha = plots
        .iter()
        .find(|plot| plot.function_name == "bench_alpha")
        .expect("bench_alpha plot");
    let beta = plots
        .iter()
        .find(|plot| plot.function_name == "bench_beta")
        .expect("bench_beta plot");

    assert_eq!(alpha.devices.len(), 1);
    assert_eq!(alpha.devices[0].samples_ns, vec![100, 200, 300]);
    assert_eq!(beta.devices.len(), 1);
    assert_eq!(beta.devices[0].samples_ns, vec![400, 500, 600]);
}

#[test]
fn extract_function_plot_inputs_preserves_duplicate_runs_from_non_root_files() {
    let root = tempfile::tempdir().expect("tempdir");
    let root_summary = root.path().join("summary.json");
    let nested_dir = root.path().join("android").join("bench_alpha");
    fs::create_dir_all(&nested_dir).expect("create nested dir");

    write_json(
        &root_summary,
        serde_json::json!({
            "summary": {
                "generated_at": "2026-03-25T00:00:00Z",
                "generated_at_unix": 1_000_000_002_u64,
                "target": "android",
                "function": "bench_beta",
                "iterations": 3,
                "warmup": 1,
                "devices": ["Google Pixel 8-14.0"],
                "device_summaries": [{
                    "device": "Google Pixel 8-14.0",
                    "benchmarks": [{
                        "function": "bench_beta",
                        "samples": 3,
                        "mean_ns": 333_u64,
                        "median_ns": 300_u64,
                        "p95_ns": 400_u64,
                        "min_ns": 300_u64,
                        "max_ns": 400_u64
                    }]
                }]
            },
            "benchmark_results": {
                "Google Pixel 8-14.0": [{
                    "function": "bench_beta",
                    "samples": [300_u64, 300_u64, 400_u64]
                }]
            }
        }),
    );

    write_json(
        &nested_dir.join("summary.json"),
        serde_json::json!({
            "summary": {
                "generated_at": "2026-03-25T00:00:00Z",
                "generated_at_unix": 1_000_000_003_u64,
                "target": "android",
                "function": "bench_alpha",
                "iterations": 3,
                "warmup": 1,
                "devices": ["Google Pixel 8-14.0"],
                "device_summaries": [{
                    "device": "Google Pixel 8-14.0",
                    "benchmarks": [{
                        "function": "bench_alpha",
                        "samples": 3,
                        "mean_ns": 133_u64,
                        "median_ns": 100_u64,
                        "p95_ns": 200_u64,
                        "min_ns": 100_u64,
                        "max_ns": 200_u64
                    }]
                }]
            },
            "benchmark_results": {
                "Google Pixel 8-14.0": [
                    {
                        "function": "bench_alpha",
                        "samples": [100_u64, 100_u64, 200_u64]
                    },
                    {
                        "function": "bench_alpha",
                        "samples": [100_u64, 100_u64, 200_u64]
                    }
                ]
            }
        }),
    );

    let plots =
        extract_function_plot_inputs_from_results_dir(root.path()).expect("extract plot inputs");

    let alpha = plots
        .iter()
        .find(|plot| plot.function_name == "bench_alpha")
        .expect("bench_alpha plot");
    let beta = plots
        .iter()
        .find(|plot| plot.function_name == "bench_beta")
        .expect("bench_beta plot");

    assert_eq!(alpha.devices.len(), 1);
    assert_eq!(
        alpha.devices[0].samples_ns,
        vec![100, 100, 100, 100, 200, 200]
    );
    assert_eq!(beta.devices[0].samples_ns, vec![300, 300, 400]);
}

#[test]
fn render_plots_auto_mode_skips_missing_python() {
    let out = tempfile::tempdir().expect("tempdir");
    let inputs = vec![sample_plot_input()];

    let rendered = render_plot_artifacts(
        &inputs,
        out.path(),
        PlotMode::Auto,
        Some(Path::new("/definitely/missing/python")),
    )
    .expect("auto mode should not fail");

    assert!(rendered.is_empty());
}

#[test]
fn materialize_renderer_writes_script_and_style() {
    let out = tempfile::tempdir().expect("tempdir");
    let bundle = materialize_renderer_assets(out.path()).expect("materialize renderer");

    assert!(bundle.script_path.exists());
    assert!(bundle.style_path.exists());
    assert_eq!(
        fs::read_to_string(&bundle.script_path).expect("read script"),
        PLOT_SCRIPT
    );
    assert_eq!(
        fs::read_to_string(&bundle.style_path).expect("read style"),
        PLOT_STYLE
    );
}

#[test]
fn allocate_plot_file_names_deduplicates_function_labels() {
    let first = sample_plot_input();
    let mut second = sample_plot_input();
    second.target = "ios".to_string();

    assert_eq!(
        allocate_plot_file_names(&[first, second]),
        vec![
            "nullifier-proof-generation.svg".to_string(),
            "nullifier-proof-generation-ios.svg".to_string()
        ]
    );
}

#[cfg(unix)]
#[test]
fn render_plot_artifacts_invokes_renderer_with_fake_python() {
    use std::os::unix::fs::PermissionsExt;

    let out = tempfile::tempdir().expect("tempdir");
    let fake_python = out.path().join("fake-python");
    fs::write(
        &fake_python,
        r#"#!/bin/sh
if [ "$1" = "--version" ]; then
  exit 0
fi

output=""
while [ "$#" -gt 0 ]; do
  if [ "$1" = "--output" ]; then
    shift
    output="$1"
  fi
  shift
done

mkdir -p "$(dirname "$output")"
printf '<svg>ok</svg>' > "$output"
"#,
    )
    .expect("write fake python");

    let mut permissions = fs::metadata(&fake_python)
        .expect("fake python metadata")
        .permissions();
    permissions.set_mode(0o755);
    fs::set_permissions(&fake_python, permissions).expect("set fake python perms");

    let rendered = render_plot_artifacts(
        &[sample_plot_input()],
        out.path(),
        PlotMode::Require,
        Some(&fake_python),
    )
    .expect("render plots");

    assert_eq!(rendered.len(), 1);
    assert_eq!(
        rendered[0].relative_path,
        PathBuf::from("plots/nullifier-proof-generation.svg")
    );
    assert_eq!(
        fs::read_to_string(&rendered[0].output_path).expect("read svg"),
        "<svg>ok</svg>"
    );
}

#[cfg_attr(not(test), allow(dead_code))]
pub fn extract_function_plot_inputs_from_results_dir(dir: &Path) -> Result<Vec<PlotFunctionInput>> {
    let mut builders = BTreeMap::new();
    let mut nested_source_keys = BTreeSet::new();

    collect_from_results_dir(dir, &mut builders, &mut nested_source_keys)?;

    Ok(finish_plot_inputs(builders))
}

pub fn extract_function_plot_inputs_from_output_value(
    value: &Value,
) -> Result<Vec<PlotFunctionInput>> {
    let mut builders = BTreeMap::new();
    let mut nested_source_keys = BTreeSet::new();
    collect_from_output_value(
        value,
        Path::new("summary.json"),
        &mut builders,
        &mut nested_source_keys,
    )?;
    Ok(finish_plot_inputs(builders))
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PlotMode {
    Auto,
    Off,
    Require,
}

#[derive(Debug)]
pub struct RendererAssets {
    _tempdir: ManagedTempDir,
    pub script_path: PathBuf,
    #[allow(dead_code)]
    pub style_path: PathBuf,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedPlot {
    pub function_name: String,
    pub function_label: String,
    pub target: String,
    pub output_path: PathBuf,
    pub relative_path: PathBuf,
}

const PLOT_SCRIPT_NAME: &str = "render_sina_plot.py";
const PLOT_STYLE_NAME: &str = "mobench_light.mplstyle";
const PLOT_SCRIPT: &str = include_str!("../python/render_sina_plot.py");
const PLOT_STYLE: &str = include_str!("../python/mobench_light.mplstyle");

static ASSET_DIR_SEQ: AtomicU64 = AtomicU64::new(0);

#[derive(Debug)]
struct ManagedTempDir {
    path: PathBuf,
}

impl ManagedTempDir {
    fn new_in(parent: &Path) -> Result<Self> {
        fs::create_dir_all(parent)
            .with_context(|| format!("creating directory {}", parent.display()))?;

        for _ in 0..1024 {
            let seq = ASSET_DIR_SEQ.fetch_add(1, Ordering::Relaxed);
            let candidate = parent.join(format!(".mobench-plot-assets-{seq}"));
            match fs::create_dir(&candidate) {
                Ok(()) => return Ok(Self { path: candidate }),
                Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
                Err(err) => {
                    return Err(err).with_context(|| {
                        format!("creating temporary directory {}", candidate.display())
                    });
                }
            }
        }

        anyhow::bail!("failed to allocate a temporary plot asset directory")
    }

    fn path(&self) -> &Path {
        &self.path
    }
}

impl Drop for ManagedTempDir {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.path);
    }
}

pub fn materialize_renderer_assets(output_dir: &Path) -> Result<RendererAssets> {
    let tempdir = ManagedTempDir::new_in(output_dir)?;
    let script_path = tempdir.path().join(PLOT_SCRIPT_NAME);
    let style_path = tempdir.path().join(PLOT_STYLE_NAME);

    fs::write(&script_path, PLOT_SCRIPT)
        .with_context(|| format!("writing renderer script {}", script_path.display()))?;
    fs::write(&style_path, PLOT_STYLE)
        .with_context(|| format!("writing renderer style {}", style_path.display()))?;

    Ok(RendererAssets {
        _tempdir: tempdir,
        script_path,
        style_path,
    })
}

pub fn render_plot_artifacts(
    inputs: &[PlotFunctionInput],
    output_dir: &Path,
    mode: PlotMode,
    python_override: Option<&Path>,
) -> Result<Vec<RenderedPlot>> {
    match mode {
        PlotMode::Off => Ok(Vec::new()),
        PlotMode::Auto | PlotMode::Require => {
            if inputs.is_empty() {
                return Ok(Vec::new());
            }

            let assets = materialize_renderer_assets(output_dir)?;
            let plots_dir = output_dir.join("plots");
            fs::create_dir_all(&plots_dir)
                .with_context(|| format!("creating directory {}", plots_dir.display()))?;
            let file_names = allocate_plot_file_names(inputs);

            let mut rendered = Vec::new();
            for (input, file_name) in inputs.iter().zip(file_names.iter()) {
                match render_single_plot(input, &plots_dir, &assets, python_override, file_name) {
                    Ok(plot) => rendered.push(plot),
                    Err(err) if mode == PlotMode::Auto => {
                        eprintln!("Skipping plot {}: {err}", input.function_name);
                    }
                    Err(err) => return Err(err),
                }
            }

            Ok(rendered)
        }
    }
}

fn render_single_plot(
    input: &PlotFunctionInput,
    plots_dir: &Path,
    assets: &RendererAssets,
    python_override: Option<&Path>,
    file_name: &str,
) -> Result<RenderedPlot> {
    let output_path = plots_dir.join(file_name);
    let relative_path = PathBuf::from("plots").join(file_name);
    let input_path = assets
        .script_path
        .parent()
        .context("renderer assets missing parent directory")?
        .join(format!(
            "{}.json",
            slugify_for_filename(&input.function_name)
        ));
    let payload = serde_json::to_vec_pretty(input).context("serializing plot input")?;
    fs::write(&input_path, payload)
        .with_context(|| format!("writing plot input {}", input_path.display()))?;

    let mut last_not_found = None;
    for candidate in python_candidates(python_override) {
        match try_render_with_python(&candidate, &assets.script_path, &input_path, &output_path) {
            Ok(()) => {
                return Ok(RenderedPlot {
                    function_name: input.function_name.clone(),
                    function_label: input.function_label.clone(),
                    target: input.target.clone(),
                    output_path,
                    relative_path,
                });
            }
            Err(RenderAttemptError::NotFound(err)) => last_not_found = Some(err),
            Err(RenderAttemptError::Failed(err)) => return Err(err),
        }
    }

    Err(last_not_found
        .map(anyhow::Error::from)
        .unwrap_or_else(|| anyhow::anyhow!("no usable python interpreter found")))
}

enum RenderAttemptError {
    NotFound(io::Error),
    Failed(anyhow::Error),
}

fn try_render_with_python(
    python: &Path,
    script_path: &Path,
    input_path: &Path,
    output_path: &Path,
) -> std::result::Result<(), RenderAttemptError> {
    let output = Command::new(python)
        .arg(script_path)
        .arg("--input")
        .arg(input_path)
        .arg("--output")
        .arg(output_path)
        .output();

    let output = match output {
        Ok(output) => output,
        Err(err) if err.kind() == io::ErrorKind::NotFound => {
            return Err(RenderAttemptError::NotFound(err));
        }
        Err(err) => return Err(RenderAttemptError::Failed(err.into())),
    };

    if output.status.success() {
        return Ok(());
    }

    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
    let details = match (stdout.is_empty(), stderr.is_empty()) {
        (true, true) => "renderer exited unsuccessfully".to_string(),
        (false, true) => format!("renderer stdout: {stdout}"),
        (true, false) => format!("renderer stderr: {stderr}"),
        (false, false) => format!("renderer stdout: {stdout}; stderr: {stderr}"),
    };

    Err(RenderAttemptError::Failed(anyhow::anyhow!(
        "{} failed for {}",
        python.display(),
        details
    )))
}

fn python_candidates(python_override: Option<&Path>) -> Vec<PathBuf> {
    if let Some(path) = python_override {
        return vec![path.to_path_buf()];
    }

    let mut candidates = Vec::new();
    if let Ok(value) = std::env::var("MOBENCH_PLOT_PYTHON") {
        let trimmed = value.trim();
        if !trimmed.is_empty() {
            candidates.push(PathBuf::from(trimmed));
        }
    }
    candidates.push(PathBuf::from("python3"));
    candidates.push(PathBuf::from("python"));
    candidates
}

fn allocate_plot_file_names(inputs: &[PlotFunctionInput]) -> Vec<String> {
    let mut used = BTreeSet::new();
    inputs
        .iter()
        .map(|input| {
            let base = slugify_for_filename(&input.function_label);
            let target = slugify_for_filename(&input.target);
            let function = slugify_for_filename(&input.function_name);

            for candidate in [
                Some(base.clone()),
                (!target.is_empty()).then(|| format!("{base}-{target}")),
                (function != base && !function.is_empty()).then(|| format!("{base}-{function}")),
            ]
            .into_iter()
            .flatten()
            {
                if used.insert(candidate.clone()) {
                    return format!("{candidate}.svg");
                }
            }

            let mut index = 2usize;
            loop {
                let candidate = format!("{base}-{index}");
                if used.insert(candidate.clone()) {
                    return format!("{candidate}.svg");
                }
                index += 1;
            }
        })
        .collect()
}

fn slugify_for_filename(value: &str) -> String {
    let mut slug = String::new();
    let mut previous_dash = false;

    for ch in value.to_ascii_lowercase().chars() {
        if ch.is_ascii_alphanumeric() {
            slug.push(ch);
            previous_dash = false;
        } else if !previous_dash {
            slug.push('-');
            previous_dash = true;
        }
    }

    while slug.starts_with('-') {
        slug.remove(0);
    }
    while slug.ends_with('-') {
        slug.pop();
    }

    if slug.is_empty() {
        "plot".to_string()
    } else {
        slug
    }
}

#[cfg(test)]
fn sample_plot_input() -> PlotFunctionInput {
    PlotFunctionInput {
        function_name: "bench_nullifier_proof_generation".to_string(),
        function_label: "nullifier-proof-generation".to_string(),
        target: "android".to_string(),
        iterations: 20,
        warmup: 5,
        devices: vec![PlotDeviceSamples {
            device_name: "iPhone 15".to_string(),
            os_version: "iOS 17.4".to_string(),
            samples_ns: vec![10_000_000, 11_000_000],
        }],
    }
}

#[derive(Debug, Default)]
struct PlotFunctionInputBuilder {
    function_name: String,
    function_label: String,
    target: String,
    iterations: u32,
    warmup: u32,
    devices: BTreeMap<(String, String), PlotDeviceSamplesBuilder>,
}

#[derive(Debug, Default)]
struct PlotDeviceSamplesBuilder {
    device_name: String,
    os_version: String,
    samples_ns: Vec<u64>,
}

impl PlotFunctionInputBuilder {
    fn new(function_name: String, target: String) -> Self {
        Self {
            function_label: humanize_benchmark_name(&function_name),
            function_name,
            target,
            iterations: 0,
            warmup: 0,
            devices: BTreeMap::new(),
        }
    }

    fn set_run_metadata(&mut self, iterations: u32, warmup: u32) {
        if self.iterations == 0 {
            self.iterations = iterations;
        }
        if self.warmup == 0 {
            self.warmup = warmup;
        }
    }

    fn add_device_samples(
        &mut self,
        device_name: String,
        os_version: String,
        samples_ns: Vec<u64>,
    ) {
        if samples_ns.is_empty() {
            return;
        }

        let key = (device_name.clone(), os_version.clone());
        let device = self
            .devices
            .entry(key)
            .or_insert_with(|| PlotDeviceSamplesBuilder {
                device_name,
                os_version,
                samples_ns: Vec::new(),
            });
        device.samples_ns.extend(samples_ns);
    }

    fn finish(self) -> PlotFunctionInput {
        PlotFunctionInput {
            function_name: self.function_name,
            function_label: self.function_label,
            target: self.target,
            iterations: self.iterations,
            warmup: self.warmup,
            devices: self
                .devices
                .into_values()
                .map(|mut device| {
                    device.samples_ns.sort_unstable();
                    PlotDeviceSamples {
                        device_name: device.device_name,
                        os_version: device.os_version,
                        samples_ns: device.samples_ns,
                    }
                })
                .collect(),
        }
    }
}

#[cfg_attr(not(test), allow(dead_code))]
fn collect_from_results_dir(
    dir: &Path,
    builders: &mut BTreeMap<(String, String), PlotFunctionInputBuilder>,
    nested_source_keys: &mut BTreeSet<PlotEntryKey>,
) -> Result<()> {
    let mut json_paths = Vec::new();
    collect_json_files(dir, &mut json_paths)?;
    json_paths.sort();

    let root_summary_path = dir.join("summary.json");
    let mut nested_paths = Vec::new();
    let mut root_paths = Vec::new();
    for path in json_paths {
        if path == root_summary_path {
            root_paths.push(path);
        } else {
            nested_paths.push(path);
        }
    }

    for path in nested_paths {
        let value = read_json(&path)?;
        collect_from_value(&value, &path, false, builders, nested_source_keys)?;
    }

    for path in root_paths {
        let value = read_json(&path)?;
        collect_from_value(&value, &path, true, builders, nested_source_keys)?;
    }

    Ok(())
}

fn collect_from_output_value(
    value: &Value,
    path: &Path,
    builders: &mut BTreeMap<(String, String), PlotFunctionInputBuilder>,
    nested_source_keys: &mut BTreeSet<PlotEntryKey>,
) -> Result<()> {
    collect_from_value(value, path, false, builders, nested_source_keys)?;

    if let Some(functions) = value
        .get("functions")
        .and_then(|functions| functions.as_object())
    {
        for entry in functions.values() {
            collect_from_output_value(entry, path, builders, nested_source_keys)?;
        }
    }

    if let Some(targets) = value.get("targets").and_then(|targets| targets.as_object()) {
        for entry in targets.values() {
            collect_from_output_value(entry, path, builders, nested_source_keys)?;
        }
    }

    Ok(())
}

fn finish_plot_inputs(
    builders: BTreeMap<(String, String), PlotFunctionInputBuilder>,
) -> Vec<PlotFunctionInput> {
    let mut plots = builders
        .into_values()
        .map(PlotFunctionInputBuilder::finish)
        .collect::<Vec<_>>();
    plots.sort_by(|left, right| {
        left.target
            .cmp(&right.target)
            .then(left.function_name.cmp(&right.function_name))
    });
    plots
}

fn collect_from_value(
    value: &Value,
    path: &Path,
    is_root_summary: bool,
    builders: &mut BTreeMap<(String, String), PlotFunctionInputBuilder>,
    nested_source_keys: &mut BTreeSet<PlotEntryKey>,
) -> Result<()> {
    if let Some(benchmark_results) = value
        .get("benchmark_results")
        .and_then(|value| value.as_object())
    {
        let (target, iterations, warmup) = extract_run_metadata(value, path);

        for (device_label, entries) in benchmark_results {
            let Some(entries) = entries.as_array() else {
                continue;
            };

            for entry in entries {
                let Some(function_name) = extract_function_name(entry) else {
                    continue;
                };
                let samples_ns = extract_samples_for_plot(entry);
                if samples_ns.is_empty() {
                    continue;
                }
                let (device_name, os_version) = parse_device_string(device_label);
                let entry_key = PlotEntryKey::new(
                    &target,
                    &function_name,
                    &device_name,
                    &os_version,
                    iterations,
                    warmup,
                    &samples_ns,
                );
                if is_root_summary && nested_source_keys.contains(&entry_key) {
                    continue;
                }
                if !is_root_summary {
                    nested_source_keys.insert(entry_key);
                }

                let key = (target.clone(), function_name.clone());
                let builder = builders.entry(key).or_insert_with(|| {
                    PlotFunctionInputBuilder::new(function_name, target.clone())
                });
                builder.set_run_metadata(iterations, warmup);
                builder.add_device_samples(device_name, os_version, samples_ns);
            }
        }

        return Ok(());
    }

    if value.get("spec").is_some() {
        let (target, iterations, warmup) = extract_run_metadata(value, path);
        let Some(function_name) = extract_function_name(value) else {
            return Ok(());
        };
        let samples_ns = extract_samples_for_plot(value);
        if samples_ns.is_empty() {
            return Ok(());
        }
        let (device_name, os_version) = value
            .get("device")
            .and_then(|value| value.as_str())
            .map(parse_device_string)
            .unwrap_or_else(|| infer_device_from_path(path));
        let entry_key = PlotEntryKey::new(
            &target,
            &function_name,
            &device_name,
            &os_version,
            iterations,
            warmup,
            &samples_ns,
        );
        if is_root_summary && nested_source_keys.contains(&entry_key) {
            return Ok(());
        }
        if !is_root_summary {
            nested_source_keys.insert(entry_key);
        }

        let key = (target.clone(), function_name.clone());
        let builder = builders
            .entry(key)
            .or_insert_with(|| PlotFunctionInputBuilder::new(function_name, target));
        builder.set_run_metadata(iterations, warmup);
        builder.add_device_samples(device_name, os_version, samples_ns);
    }

    Ok(())
}

fn extract_run_metadata(value: &Value, path: &Path) -> (String, u32, u32) {
    let summary = value.get("summary").unwrap_or(value);

    let target = summary
        .get("target")
        .or_else(|| value.get("target"))
        .and_then(|value| value.as_str())
        .map(str::to_string)
        .unwrap_or_else(|| infer_target_from_path(path));

    let iterations = summary
        .get("iterations")
        .and_then(|value| value.as_u64())
        .unwrap_or(0) as u32;
    let warmup = summary
        .get("warmup")
        .and_then(|value| value.as_u64())
        .unwrap_or(0) as u32;

    (target, iterations, warmup)
}

fn extract_function_name(value: &Value) -> Option<String> {
    value
        .get("function")
        .and_then(|value| value.as_str())
        .or_else(|| {
            value
                .get("spec")
                .and_then(|spec| spec.get("name"))
                .and_then(|value| value.as_str())
        })
        .map(str::to_string)
}

fn extract_samples_for_plot(value: &Value) -> Vec<u64> {
    let mut samples = crate::extract_samples(value);
    if samples.is_empty()
        && let Some(samples_ns) = value.get("samples_ns").and_then(|value| value.as_array())
    {
        samples.extend(samples_ns.iter().filter_map(|value| value.as_u64()));
    }
    samples.sort_unstable();
    samples
}

fn parse_device_string(s: &str) -> (String, String) {
    match s.rsplit_once('-') {
        Some((name, version)) if !name.is_empty() && !version.is_empty() => {
            (name.to_string(), version.to_string())
        }
        _ => (s.to_string(), "unknown".to_string()),
    }
}

fn infer_device_from_path(_path: &Path) -> (String, String) {
    ("unknown".to_string(), "unknown".to_string())
}

fn infer_target_from_path(path: &Path) -> String {
    let lower_path = path.to_string_lossy().to_ascii_lowercase();
    if lower_path.contains("/ios/") || lower_path.contains("\\ios\\") {
        "ios".to_string()
    } else if lower_path.contains("/android/") || lower_path.contains("\\android\\") {
        "android".to_string()
    } else {
        "unknown".to_string()
    }
}

fn humanize_benchmark_name(name: &str) -> String {
    let leaf = name.rsplit("::").next().unwrap_or(name);
    let s = leaf.strip_prefix("bench_").unwrap_or(leaf);
    s.replace('_', "-")
}

#[cfg_attr(not(test), allow(dead_code))]
fn collect_json_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
    let mut entries = fs::read_dir(dir)
        .with_context(|| format!("Failed to read results directory {}", dir.display()))?
        .collect::<std::result::Result<Vec<_>, _>>()
        .with_context(|| format!("Failed to iterate results directory {}", dir.display()))?;
    entries.sort_by_key(|entry| entry.path());

    for entry in entries {
        let path = entry.path();
        if path.is_dir() {
            collect_json_files(&path, out)?;
        } else if path.extension().is_some_and(|ext| ext == "json") {
            out.push(path);
        }
    }

    Ok(())
}

#[cfg_attr(not(test), allow(dead_code))]
fn read_json(path: &Path) -> Result<Value> {
    let content =
        fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
    serde_json::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))
}

#[cfg(test)]
fn write_json(path: &Path, value: Value) {
    fs::write(
        path,
        serde_json::to_vec_pretty(&value).expect("serialize json"),
    )
    .expect("write json");
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct PlotEntryKey {
    target: String,
    function_name: String,
    device_name: String,
    os_version: String,
    iterations: u32,
    warmup: u32,
    samples_ns: Vec<u64>,
}

impl PlotEntryKey {
    fn new(
        target: &str,
        function_name: &str,
        device_name: &str,
        os_version: &str,
        iterations: u32,
        warmup: u32,
        samples_ns: &[u64],
    ) -> Self {
        Self {
            target: target.to_string(),
            function_name: function_name.to_string(),
            device_name: device_name.to_string(),
            os_version: os_version.to_string(),
            iterations,
            warmup,
            samples_ns: samples_ns.to_vec(),
        }
    }
}
