//! Context generator planning and parallel execution (loctree/tsc-trace/tauri info).

use super::*;

pub(super) fn plan_context_artifacts(
    config: &Config,
    diffs: &[Diff],
    checks: &[CheckResult],
) -> Vec<ContextArtifactDecision> {
    let mut decisions = Vec::new();

    if config.profile.has_tsconfig {
        decisions.push(plan_tsc_trace_artifact(config, diffs, checks));
    }
    if has_tauri_context(config) {
        decisions.push(plan_tauri_info_artifact(config, diffs));
    }

    decisions
}

pub(super) fn plan_tsc_trace_artifact(
    config: &Config,
    diffs: &[Diff],
    checks: &[CheckResult],
) -> ContextArtifactDecision {
    if !config.is_fast_remote_only_standard() {
        return ContextArtifactDecision {
            key: "tsc_trace",
            path: "30_context/tsc-trace.log",
            generated: true,
            recommended: false,
            reason: "generated by default for this run mode".to_string(),
        };
    }

    let mut reasons = Vec::new();
    if let Some(reason) = detect_typescript_resolution_signal(checks) {
        reasons.push(reason);
    }

    let changed_resolution_files = find_ts_resolution_related_changes(diffs);
    if !changed_resolution_files.is_empty() {
        reasons.push(format!(
            "resolution-related files changed ({})",
            changed_resolution_files.join(", ")
        ));
    }

    if reasons.is_empty() {
        ContextArtifactDecision {
            key: "tsc_trace",
            path: "30_context/tsc-trace.log",
            generated: false,
            recommended: false,
            reason: "skipped by default in fast remote-only runs; no TypeScript resolution signals detected"
                .to_string(),
        }
    } else {
        ContextArtifactDecision {
            key: "tsc_trace",
            path: "30_context/tsc-trace.log",
            generated: false,
            recommended: true,
            reason: format!(
                "skipped by default in fast remote-only runs; generate when investigating because {}",
                reasons.join("; ")
            ),
        }
    }
}

pub(super) fn detect_typescript_resolution_signal(checks: &[CheckResult]) -> Option<String> {
    let typescript = checks
        .iter()
        .find(|check| check.name.eq_ignore_ascii_case("TypeScript"))?;
    if !typescript.is_failure() {
        return None;
    }

    let output = typescript.output.to_lowercase();
    const RESOLUTION_NEEDLES: &[&str] = &[
        "cannot find module",
        "cannot resolve module",
        "module resolution",
        "did you mean to set the module resolution option",
        "paths option",
        "baseurl",
    ];
    if RESOLUTION_NEEDLES
        .iter()
        .any(|needle| output.contains(needle))
    {
        Some("TypeScript check failed with module-resolution-style errors".to_string())
    } else {
        None
    }
}

pub(super) fn find_ts_resolution_related_changes(diffs: &[Diff]) -> Vec<String> {
    let mut matches = Vec::new();

    for file in diffs.iter().flat_map(|diff| &diff.files) {
        let path = file.path.as_str();
        let file_name = Path::new(path)
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or(path);
        let is_tsconfig = file_name.starts_with("tsconfig") && file_name.ends_with(".json");
        let is_resolution_config = matches!(
            file_name,
            "package.json"
                | "pnpm-lock.yaml"
                | "package-lock.json"
                | "yarn.lock"
                | "vite.config.ts"
                | "vite.config.js"
                | "vite.config.mjs"
                | "vite.config.cjs"
                | "webpack.config.js"
                | "webpack.config.ts"
                | "tsup.config.ts"
                | "tsup.config.js"
        );

        if is_tsconfig || is_resolution_config {
            matches.push(path.to_string());
        }
    }

    matches.sort();
    matches.dedup();
    matches.truncate(3);
    matches
}

pub(super) fn has_tauri_context(config: &Config) -> bool {
    if !config.profile.has_cargo {
        return false;
    }
    is_tauri_project(&config.repo_root)
}

/// Detect whether the repository is actually a Tauri project.
///
/// Checks (in order):
/// 1. `tauri.conf.json` or `tauri.conf.toml` in the repo root or `src-tauri/`
/// 2. `src-tauri/Cargo.toml` exists (the canonical Tauri crate location)
/// 3. Root or workspace `Cargo.toml` lists `tauri` as a dependency
pub(super) fn is_tauri_project(repo_root: &Path) -> bool {
    // tauri.conf.json / tauri.conf.toml in standard locations
    let conf_files = [
        repo_root.join("tauri.conf.json"),
        repo_root.join("tauri.conf.toml"),
        repo_root.join("src-tauri").join("tauri.conf.json"),
        repo_root.join("src-tauri").join("tauri.conf.toml"),
    ];
    if conf_files.iter().any(|p| p.exists()) {
        return true;
    }

    // src-tauri/Cargo.toml — canonical Tauri structure
    if repo_root.join("src-tauri").join("Cargo.toml").exists() {
        return true;
    }

    // Cargo.toml contains tauri as a dependency
    let cargo_toml_path = repo_root.join("Cargo.toml");
    if let Ok(content) = fs::read_to_string(&cargo_toml_path)
        && content.contains("tauri")
    {
        return true;
    }

    false
}

pub(super) fn plan_tauri_info_artifact(config: &Config, diffs: &[Diff]) -> ContextArtifactDecision {
    if !config.is_fast_remote_only_standard() {
        return ContextArtifactDecision {
            key: "tauri_info",
            path: "30_context/tauri-info.log",
            generated: true,
            recommended: false,
            reason: "generated by default for this run mode".to_string(),
        };
    }

    let changed_tauri_files = find_tauri_diagnostic_changes(diffs);
    if changed_tauri_files.is_empty() {
        ContextArtifactDecision {
            key: "tauri_info",
            path: "30_context/tauri-info.log",
            generated: false,
            recommended: false,
            reason:
                "skipped by default in fast remote-only runs; no Tauri config/build signals detected"
                    .to_string(),
        }
    } else {
        ContextArtifactDecision {
            key: "tauri_info",
            path: "30_context/tauri-info.log",
            generated: false,
            recommended: true,
            reason: format!(
                "skipped by default in fast remote-only runs; generate when investigating because Tauri config/build files changed ({})",
                changed_tauri_files.join(", ")
            ),
        }
    }
}

pub(super) fn find_tauri_diagnostic_changes(diffs: &[Diff]) -> Vec<String> {
    let mut matches = Vec::new();

    for file in diffs.iter().flat_map(|diff| &diff.files) {
        let path = file.path.as_str();
        let file_name = Path::new(path)
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or(path);
        let is_tauri_config = matches!(
            file_name,
            "tauri.conf.json"
                | "tauri.linux.conf.json"
                | "tauri.macos.conf.json"
                | "tauri.windows.conf.json"
                | "build.rs"
                | "Cargo.toml"
        );
        let is_tauri_capability = path.starts_with("src-tauri/capabilities/");

        if is_tauri_config || is_tauri_capability {
            matches.push(path.to_string());
        }
    }

    matches.sort();
    matches.dedup();
    matches.truncate(3);
    matches
}

/// Descriptor for an external context command to run in parallel.
pub(super) struct ContextCmd {
    pub(super) label: String,
    pub(super) cmd: String,
    pub(super) args: Vec<String>,
    pub(super) cwd: PathBuf,
    pub(super) out_dir: PathBuf,
    pub(super) out_file: String,
}

/// Resolve the command for the optional `tauri info` context artifact.
///
/// Prefers a locally-installed tauri binary (a direct exec with no npm consult
/// and no interactive prompt), then `pnpm`. Returns `None` when neither is
/// available so the caller never falls through to `npx --no-install`, which
/// still consults npm and, for a missing CLI, can hit the network and hang
/// until the context timeout instead of recording the artifact as unavailable
/// (PR #12 review). Mirrors the local-bin-first pattern in `checks` (38c4b01).
fn tauri_info_cmd(repo_root: &Path, has_pnpm: bool) -> Option<(String, Vec<String>)> {
    if let Some(bin) = crate::checks::local_js_bin("tauri", repo_root) {
        Some((bin.to_string_lossy().into_owned(), vec!["info".into()]))
    } else if has_pnpm {
        Some(("pnpm".to_string(), vec!["tauri".into(), "info".into()]))
    } else {
        None
    }
}

/// Resolve the command for an optional `pnpm exec`-class JS-tool context
/// artifact (tsc, eslint, esbuild, ...).
///
/// Prefers a locally-installed `node_modules/.bin/<tool>` binary (a direct exec
/// with no npm consult and no interactive prompt), then `pnpm exec <tool>`.
/// Returns `None` when neither is available so the caller skips the optional
/// artifact rather than falling through to `npx --no-install`, which still
/// consults npm and, for a missing CLI, can hit the network and hang until the
/// context timeout instead of recording the artifact as unavailable
/// (PR #12 review). Mirrors `tauri_info_cmd` and the local-bin-first pattern in
/// `checks` (38c4b01).
fn js_exec_cmd(
    tool: &str,
    tool_args: Vec<String>,
    repo_root: &Path,
    has_pnpm: bool,
) -> Option<(String, Vec<String>)> {
    if let Some(bin) = crate::checks::local_js_bin(tool, repo_root) {
        Some((bin.to_string_lossy().into_owned(), tool_args))
    } else if has_pnpm {
        let mut args = vec!["exec".to_string(), tool.to_string()];
        args.extend(tool_args);
        Some(("pnpm".to_string(), args))
    } else {
        None
    }
}

/// Run profile-specific context generators with timeouts.
/// All commands run in parallel with a shared deadline.
/// Skips tools already executed by the checks system.
pub(super) fn generate_context_artifacts(
    config: &Config,
    checks: &[CheckResult],
    context_dir: &Path,
    emit_human_stdout: bool,
    decisions: &[ContextArtifactDecision],
) -> Result<Vec<ContextCommandTiming>> {
    let ctx = context_dir.to_path_buf();
    let repo_root = config.repo_root.clone();
    let has_pnpm = which::which("pnpm").is_ok();

    let mut cmds: Vec<ContextCmd> = Vec::new();

    let tauri_info = decisions
        .iter()
        .find(|decision| decision.key == "tauri_info");

    // Cargo profile
    if config.profile.has_cargo {
        let cwd = config
            .profile
            .cargo_root
            .as_ref()
            .unwrap_or(&config.repo_root)
            .clone();
        cmds.push(ContextCmd {
            label: "cargo tree".into(),
            cmd: "cargo".into(),
            args: vec!["tree".into(), "--depth".into(), "2".into()],
            cwd: cwd.clone(),
            out_dir: ctx.clone(),
            out_file: "cargo-tree.txt".into(),
        });

        cmds.push(ContextCmd {
            label: "cargo sbom".into(),
            cmd: "cargo".into(),
            args: vec!["tree".into(), "--format".into(), "{p} {l}".into()],
            cwd: cwd.clone(),
            out_dir: ctx.clone(),
            out_file: "cargo-sbom.txt".into(),
        });

        if let Some(cargo_root) = &config.profile.cargo_root {
            let tauri_dir = if cargo_root.ends_with("src-tauri") {
                cargo_root.clone()
            } else {
                config.repo_root.join("src-tauri")
            };
            // Only generate tauri artifacts for actual Tauri projects.
            // Checking the directory alone is insufficient (leftover fixtures,
            // partial scaffolds). Require tauri.conf.json/toml, src-tauri/Cargo.toml,
            // or a "tauri" entry in the root Cargo.toml.
            if is_tauri_project(&config.repo_root) && tauri_dir.exists() {
                if tauri_info.is_none_or(|decision| decision.generated) {
                    // Resolve a directly-runnable tauri binary; skip the artifact
                    // when none is available rather than reaching npx --no-install,
                    // which still consults npm and can hang until timeout on a
                    // missing CLI (PR #12 review).
                    if let Some((cmd, args)) = tauri_info_cmd(&repo_root, has_pnpm) {
                        cmds.push(ContextCmd {
                            label: "tauri info".into(),
                            cmd,
                            args,
                            cwd: repo_root.clone(),
                            out_dir: ctx.clone(),
                            out_file: "tauri-info.log".into(),
                        });
                    }
                } else if let Some(decision) = tauri_info
                    && emit_human_stdout
                {
                    use colored::Colorize;
                    let marker = if decision.recommended {
                        "ℹ".yellow()
                    } else {
                        "ℹ".blue()
                    };
                    println!("  {} tauri-info.log: skipped ({})", marker, decision.reason);
                }
            }
        }
    }

    // TypeScript trace
    let tsc_trace = decisions
        .iter()
        .find(|decision| decision.key == "tsc_trace");

    if config.profile.has_tsconfig && tsc_trace.is_none_or(|decision| decision.generated) {
        // Resolve a directly-runnable tsc binary; skip the artifact when none is
        // available rather than reaching npx --no-install, which can hang on a
        // missing CLI (PR #12 review).
        if let Some((cmd, args)) = js_exec_cmd(
            "tsc",
            vec!["--noEmit".into(), "--traceResolution".into()],
            &repo_root,
            has_pnpm,
        ) {
            cmds.push(ContextCmd {
                label: "tsc trace".into(),
                cmd,
                args,
                cwd: repo_root.clone(),
                out_dir: ctx.clone(),
                out_file: "tsc-trace.log".into(),
            });
        }
    } else if let Some(decision) = tsc_trace
        && emit_human_stdout
    {
        use colored::Colorize;
        let marker = if decision.recommended {
            "ℹ".yellow()
        } else {
            "ℹ".blue()
        };
        println!("  {} tsc-trace.log: skipped ({})", marker, decision.reason);
    }

    // JS/TS package.json tools
    if config.profile.has_package_json {
        // `npx list` would run the npm package "list" from the registry —
        // the dependency listing lives in npm/pnpm themselves.
        let (sbom_cmd, sbom_args) = if has_pnpm {
            ("pnpm", vec!["list".into(), "--all".into()])
        } else {
            ("npm", vec!["ls".into(), "--all".into()])
        };
        cmds.push(ContextCmd {
            label: "npm sbom".into(),
            cmd: sbom_cmd.into(),
            args: sbom_args,
            cwd: repo_root.clone(),
            out_dir: ctx.clone(),
            out_file: "npm-sbom.txt".into(),
        });

        let checks_ran_eslint = checks
            .iter()
            .any(|c| c.name.to_lowercase().contains("eslint"));
        let checks_ran_stylelint = checks
            .iter()
            .any(|c| c.name.to_lowercase().contains("stylelint"));
        let checks_ran_vitest = checks
            .iter()
            .any(|c| c.name.to_lowercase().contains("vitest"));

        if !checks_ran_eslint {
            // Resolve a directly-runnable eslint binary; skip the artifact when
            // none is available rather than reaching npx --no-install, which can
            // hang on a missing CLI (PR #12 review).
            if let Some((cmd, args)) = js_exec_cmd(
                "eslint",
                vec![
                    ".".into(),
                    "--ext".into(),
                    ".ts,.tsx,.js,.jsx".into(),
                    "-f".into(),
                    "json".into(),
                ],
                &repo_root,
                has_pnpm,
            ) {
                cmds.push(ContextCmd {
                    label: "eslint json".into(),
                    cmd,
                    args,
                    cwd: repo_root.clone(),
                    out_dir: ctx.clone(),
                    out_file: "eslint.json".into(),
                });
            }
        }

        if repo_root.join("node_modules/.bin/stylelint").exists() && !checks_ran_stylelint {
            cmds.push(ContextCmd {
                label: "stylelint json".into(),
                cmd: "sh".into(),
                args: vec![
                    "-c".into(),
                    "pnpm exec stylelint 'src/**/*.css' -f json --allow-empty-input".into(),
                ],
                cwd: repo_root.clone(),
                out_dir: ctx.clone(),
                out_file: "stylelint.json".into(),
            });
        } else if checks_ran_stylelint && emit_human_stdout {
            use colored::Colorize;
            println!(
                "  {} stylelint.json: skipped (stylelint check already captured this signal)",
                "ℹ".blue()
            );
        }

        if !checks_ran_vitest && emit_human_stdout {
            use colored::Colorize;
            println!(
                "  {} vitest-report.json: skipped (use checks for test results)",
                "ℹ".blue()
            );
        }

        // esbuild meta
        if repo_root.join("node_modules/.bin/esbuild").exists() {
            let entry = if repo_root.join("src/main.tsx").exists() {
                Some("src/main.tsx")
            } else if repo_root.join("src/main.ts").exists() {
                Some("src/main.ts")
            } else {
                None
            };
            if let Some(entry) = entry {
                let meta_path = ctx.join("esbuild-meta.json");
                let meta_arg = format!("--metafile={}", meta_path.display());
                // This branch is gated on a local esbuild binary existing, so
                // js_exec_cmd resolves it to a direct exec (never npx --no-install)
                // (PR #12 review).
                if let Some((cmd, args)) = js_exec_cmd(
                    "esbuild",
                    vec![
                        entry.into(),
                        "--bundle".into(),
                        meta_arg,
                        "--log-level=error".into(),
                    ],
                    &repo_root,
                    has_pnpm,
                ) {
                    cmds.push(ContextCmd {
                        label: "esbuild meta".into(),
                        cmd,
                        args,
                        cwd: repo_root.clone(),
                        out_dir: ctx.clone(),
                        out_file: String::new(),
                    });
                }
            }
        }
    }

    if cmds.is_empty() {
        return Ok(Vec::new());
    }

    // Run all commands in parallel with a shared timeout
    Ok(run_context_cmds_parallel(
        &cmds,
        CONTEXT_GEN_TIMEOUT_SECS,
        emit_human_stdout,
    ))
}

/// Spawn all context commands in parallel and poll them with a shared timeout.
/// Each command gets `timeout_secs` from its own spawn time. Results are written
/// to the specified output files. Commands that exceed the timeout are killed.
pub(super) fn run_context_cmds_parallel(
    cmds: &[ContextCmd],
    timeout_secs: u64,
    emit: bool,
) -> Vec<ContextCommandTiming> {
    use std::time::Duration;

    struct RunningCmd {
        label: String,
        child: std::process::Child,
        started_at: Instant,
        deadline: Instant,
        out_dir: PathBuf,
        out_file: String,
        stdout_path: PathBuf,
        stderr_path: PathBuf,
        done: bool,
    }

    let mut running: Vec<RunningCmd> = Vec::new();
    let mut timings = Vec::new();

    for cmd in cmds {
        let args: Vec<&str> = cmd.args.iter().map(|s| s.as_str()).collect();
        let idx = running.len();
        let stdout_path = cmd.out_dir.join(format!(".context-cmd-{idx}.stdout.tmp"));
        let stderr_path = cmd.out_dir.join(format!(".context-cmd-{idx}.stderr.tmp"));
        let stdout_file = match File::create(&stdout_path) {
            Ok(file) => file,
            Err(_) => continue,
        };
        let stderr_file = match File::create(&stderr_path) {
            Ok(file) => file,
            Err(_) => {
                let _ = fs::remove_file(&stdout_path);
                continue;
            }
        };

        match Command::new(&cmd.cmd)
            .args(&args)
            .current_dir(&cmd.cwd)
            // Context tools must never read the operator's terminal: an
            // interactive prompt (npx install, credential ask) with stdout
            // redirected to a file is invisible and steals keystrokes.
            .stdin(std::process::Stdio::null())
            .stdout(std::process::Stdio::from(stdout_file))
            .stderr(std::process::Stdio::from(stderr_file))
            .spawn()
        {
            Ok(child) => {
                let started_at = Instant::now();
                running.push(RunningCmd {
                    label: cmd.label.clone(),
                    child,
                    started_at,
                    deadline: started_at + Duration::from_secs(timeout_secs),
                    out_dir: cmd.out_dir.clone(),
                    out_file: cmd.out_file.clone(),
                    stdout_path,
                    stderr_path,
                    done: false,
                });
            }
            Err(_) => {
                let _ = fs::remove_file(&stdout_path);
                let _ = fs::remove_file(&stderr_path);
                // Command not available: record it instead of skipping
                // silently, so the timings tell the truth about the pack.
                timings.push(ContextCommandTiming {
                    label: cmd.label.clone(),
                    artifact: None,
                    status: "spawn_failed",
                    duration_secs: 0.0,
                });
            }
        }
    }

    let poll_interval = Duration::from_millis(200);

    // Poll all until done or timed out
    while running.iter().any(|r| !r.done) {
        for r in running.iter_mut().filter(|r| !r.done) {
            match r.child.try_wait() {
                Ok(Some(exit)) => {
                    r.done = true;
                    // Collect output
                    collect_cmd_output(&r.stdout_path, &r.stderr_path, &r.out_dir, &r.out_file);
                    timings.push(ContextCommandTiming {
                        label: r.label.clone(),
                        artifact: (!r.out_file.is_empty()).then(|| {
                            Path::new("30_context")
                                .join(&r.out_file)
                                .display()
                                .to_string()
                        }),
                        status: if exit.success() {
                            "completed"
                        } else {
                            "failed"
                        },
                        duration_secs: r.started_at.elapsed().as_secs_f32(),
                    });
                }
                Ok(None) => {
                    if Instant::now() >= r.deadline {
                        let _ = r.child.kill();
                        let _ = r.child.wait();
                        r.done = true;
                        timings.push(ContextCommandTiming {
                            label: r.label.clone(),
                            artifact: (!r.out_file.is_empty()).then(|| {
                                Path::new("30_context")
                                    .join(&r.out_file)
                                    .display()
                                    .to_string()
                            }),
                            status: "timed_out",
                            duration_secs: r.started_at.elapsed().as_secs_f32(),
                        });
                        if emit {
                            use colored::Colorize;
                            eprintln!(
                                "  {} {}: killed (>{}s timeout)",
                                "○".dimmed(),
                                r.label,
                                timeout_secs,
                            );
                        }
                    }
                }
                Err(_) => {
                    r.done = true;
                    timings.push(ContextCommandTiming {
                        label: r.label.clone(),
                        artifact: (!r.out_file.is_empty()).then(|| {
                            Path::new("30_context")
                                .join(&r.out_file)
                                .display()
                                .to_string()
                        }),
                        status: "error",
                        duration_secs: r.started_at.elapsed().as_secs_f32(),
                    });
                }
            }
        }

        if running.iter().any(|r| !r.done) {
            std::thread::sleep(poll_interval);
        }
    }

    timings.sort_by(|a, b| b.duration_secs.total_cmp(&a.duration_secs));
    timings
}

/// Collect stdout+stderr from completed command temp files and write to artifact file.
pub(super) fn collect_cmd_output(
    stdout_path: &Path,
    stderr_path: &Path,
    out_dir: &Path,
    out_file: &str,
) {
    let stdout = fs::read_to_string(stdout_path).unwrap_or_default();
    let stderr = fs::read_to_string(stderr_path).unwrap_or_default();
    let _ = fs::remove_file(stdout_path);
    let _ = fs::remove_file(stderr_path);

    if out_file.is_empty() {
        return;
    }

    let combined = format!("{}\n{}", stdout, stderr);

    // Truncate large outputs
    let content = if combined.len() > MAX_TSC_TRACE_BYTES {
        let truncated = truncate_on_char_boundary(&combined, MAX_TSC_TRACE_BYTES);
        format!(
            "{}\n\n... (truncated, {} total bytes)",
            truncated,
            combined.len()
        )
    } else {
        combined
    };

    // For JSON outputs, only save if valid
    if out_file.ends_with(".json") {
        if let Some(json) = extract_valid_json(&stdout, &content)
            && let Err(e) = fs::write(out_dir.join(out_file), json)
        {
            eprintln!("  warning: failed to write artifact {out_file}: {e}");
        }
    } else if let Err(e) = fs::write(out_dir.join(out_file), &content) {
        eprintln!("  warning: failed to write artifact {out_file}: {e}");
    }
}

pub(super) fn extract_valid_json(stdout: &str, combined: &str) -> Option<String> {
    [stdout.trim(), combined.trim()]
        .into_iter()
        .find(|candidate| {
            !candidate.is_empty()
                && (candidate.starts_with('[') || candidate.starts_with('{'))
                && serde_json::from_str::<serde_json::Value>(candidate).is_ok()
        })
        .map(str::to_owned)
}

pub(super) fn build_regression_patch_text(patch_texts: &[String]) -> Option<String> {
    if patch_texts.is_empty() {
        return None;
    }

    let joined = patch_texts.join("\n");
    if joined.len() <= MAX_PATCH_TEXT_BYTES {
        return Some(joined);
    }

    // Keep the in-memory regression input bounded without risking invalid UTF-8.
    let mut truncated = truncate_on_char_boundary(&joined, MAX_PATCH_TEXT_BYTES).to_string();
    truncated
        .push_str("\n\n# [prview] Patch text truncated (>2 MB), some findings may be incomplete\n");
    Some(truncated)
}

pub(super) fn truncate_on_char_boundary(input: &str, max_bytes: usize) -> &str {
    if input.len() <= max_bytes {
        return input;
    }

    let mut end = max_bytes;
    while end > 0 && !input.is_char_boundary(end) {
        end -= 1;
    }
    &input[..end]
}

#[cfg(test)]
mod tests {
    use super::{js_exec_cmd, tauri_info_cmd};
    use std::fs;

    #[test]
    fn tauri_info_prefers_local_binary_over_npx() {
        let tmp = tempfile::tempdir().unwrap();
        let bin_dir = tmp.path().join("node_modules/.bin");
        fs::create_dir_all(&bin_dir).unwrap();
        fs::write(bin_dir.join("tauri"), "#!/bin/sh\n").unwrap();

        // has_pnpm=true must NOT win over a resolved local binary.
        let (cmd, args) = tauri_info_cmd(tmp.path(), true).expect("local binary resolves");
        assert!(
            cmd.ends_with("node_modules/.bin/tauri"),
            "expected a direct local exec, got {cmd}"
        );
        assert_eq!(args, vec!["info".to_string()]);
        assert!(
            !args.iter().any(|a| a == "--no-install"),
            "a resolved binary must never carry npx flags"
        );
    }

    #[test]
    fn tauri_info_falls_back_to_pnpm_without_local_binary() {
        let tmp = tempfile::tempdir().unwrap();
        let (cmd, args) = tauri_info_cmd(tmp.path(), true).expect("pnpm fallback");
        assert_eq!(cmd, "pnpm");
        assert_eq!(args, vec!["tauri".to_string(), "info".to_string()]);
    }

    #[test]
    fn tauri_info_skips_when_no_local_binary_and_no_pnpm() {
        let tmp = tempfile::tempdir().unwrap();
        // No local tauri and no pnpm: skip rather than fall through to
        // npx --no-install, which can hang on a missing CLI (PR #12 review).
        assert!(tauri_info_cmd(tmp.path(), false).is_none());
    }

    // Covers the pnpm-exec-class JS-tool sites (tsc trace, eslint json,
    // esbuild meta) that previously fell through to `npx --no-install`.
    #[test]
    fn js_exec_prefers_local_binary_over_npx() {
        for tool in ["tsc", "eslint", "esbuild"] {
            let tmp = tempfile::tempdir().unwrap();
            let bin_dir = tmp.path().join("node_modules/.bin");
            fs::create_dir_all(&bin_dir).unwrap();
            fs::write(bin_dir.join(tool), "#!/bin/sh\n").unwrap();

            // has_pnpm=true must NOT win over a resolved local binary.
            let (cmd, args) =
                js_exec_cmd(tool, vec!["--flag".into()], tmp.path(), true).expect("local resolves");
            assert!(
                cmd.ends_with(&format!("node_modules/.bin/{tool}")),
                "expected a direct local exec for {tool}, got {cmd}"
            );
            // A resolved binary carries only the tool args, no `exec`/npx flags.
            assert_eq!(args, vec!["--flag".to_string()], "tool={tool}");
            assert!(
                !args.iter().any(|a| a == "--no-install" || a == "exec"),
                "a resolved binary must never carry launcher flags (tool={tool})"
            );
        }
    }

    #[test]
    fn js_exec_falls_back_to_pnpm_exec_without_local_binary() {
        let tmp = tempfile::tempdir().unwrap();
        let (cmd, args) =
            js_exec_cmd("eslint", vec!["--flag".into()], tmp.path(), true).expect("pnpm fallback");
        assert_eq!(cmd, "pnpm");
        assert_eq!(
            args,
            vec![
                "exec".to_string(),
                "eslint".to_string(),
                "--flag".to_string()
            ]
        );
        assert!(
            !args.iter().any(|a| a == "--no-install"),
            "pnpm exec fallback must never carry npx flags"
        );
    }

    #[test]
    fn js_exec_skips_when_no_local_binary_and_no_pnpm() {
        let tmp = tempfile::tempdir().unwrap();
        // No local binary and no pnpm: skip rather than fall through to
        // npx --no-install, which can hang on a missing CLI (PR #12 review).
        assert!(js_exec_cmd("tsc", vec!["--flag".into()], tmp.path(), false).is_none());
    }
}
