//! `fallow coverage` - runtime coverage onboarding and inventory upload.
//!
//! Today the subtree holds four commands:
//!
//! - `setup`: resumable first-run state machine (optional license + sidecar
//!   + recipe + auto-handoff to `fallow health --runtime-coverage`).
//! - `analyze`: focused runtime coverage analysis. Local mode reads a coverage
//!   artifact; cloud mode explicitly fetches runtime facts from fallow cloud.
//! - `upload-inventory`: push a static function inventory to fallow cloud,
//!   unlocking the `untracked` filter on the dashboard by pairing runtime
//!   coverage data with the AST view of "every function that exists".
//! - `upload-source-maps`: push build source maps so bundled runtime coverage
//!   can resolve back to original source files.

use std::ffi::OsStr;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};

use fallow_config::{OutputFormat, PackageJson, WorkspaceInfo, discover_workspaces};
use fallow_core::git_env::clear_ambient_git_env;
use fallow_license::{DEFAULT_HARD_FAIL_DAYS, LicenseStatus};

use crate::health::coverage as runtime_coverage;
use crate::license;

pub use analyze::AnalyzeArgs;
pub use upload_inventory::UploadInventoryArgs;
pub use upload_source_maps::UploadSourceMapsArgs;

mod analyze;
mod cloud_client;
mod upload_inventory;
mod upload_source_maps;

const COVERAGE_DOCS_URL: &str = "https://docs.fallow.tools/analysis/runtime-coverage";

/// Subcommands for `fallow coverage`.
#[derive(Debug, Clone)]
pub enum CoverageSubcommand {
    /// Resumable first-run setup flow.
    Setup(SetupArgs),
    /// Analyze runtime coverage from a local artifact or explicit cloud source.
    Analyze(AnalyzeArgs),
    /// Upload a static function inventory to fallow cloud.
    UploadInventory(UploadInventoryArgs),
    /// Upload JavaScript source maps to fallow cloud.
    UploadSourceMaps(UploadSourceMapsArgs),
}

/// Context shared by `fallow coverage` subcommands.
pub struct RunContext<'a> {
    pub root: &'a Path,
    pub config_path: &'a Option<PathBuf>,
    pub output: OutputFormat,
    pub quiet: bool,
    pub no_cache: bool,
    pub threads: usize,
    pub explain: bool,
}

/// Arguments for `fallow coverage setup`.
#[derive(Debug, Clone, Copy, Default)]
pub struct SetupArgs {
    /// Accept all prompts automatically.
    pub yes: bool,
    /// Print instructions instead of prompting.
    pub non_interactive: bool,
    /// Emit deterministic JSON instructions without prompts, writes, installs, or network calls.
    pub json: bool,
    /// Include field definitions and warning semantics in JSON output.
    pub explain: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FrameworkKind {
    NextJs,
    NestJs,
    Nuxt,
    SvelteKit,
    Astro,
    Remix,
    ViteBrowser,
    PlainNode,
    Other,
}

impl FrameworkKind {
    const fn label(self) -> &'static str {
        match self {
            Self::NextJs => "Next.js project",
            Self::NestJs => "NestJS project",
            Self::Nuxt => "Nuxt app",
            Self::SvelteKit => "SvelteKit app",
            Self::Astro => "Astro app",
            Self::Remix => "Remix app",
            Self::ViteBrowser => "Vite browser app",
            Self::PlainNode => "Node service",
            Self::Other => "custom project",
        }
    }

    const fn id(self) -> &'static str {
        match self {
            Self::NextJs => "nextjs",
            Self::NestJs => "nestjs",
            Self::Nuxt => "nuxt",
            Self::SvelteKit => "sveltekit",
            Self::Astro => "astro",
            Self::Remix => "remix",
            Self::ViteBrowser => "vite",
            Self::PlainNode => "plain_node",
            Self::Other => "unknown",
        }
    }

    const fn runtime_targets(self) -> &'static [&'static str] {
        match self {
            Self::NextJs | Self::Nuxt | Self::SvelteKit | Self::Astro | Self::Remix => {
                &["node", "browser"]
            }
            Self::ViteBrowser => &["browser"],
            Self::NestJs | Self::PlainNode | Self::Other => &["node"],
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PackageManager {
    Npm,
    Pnpm,
    Yarn,
    Bun,
}

impl PackageManager {
    const fn label(self) -> &'static str {
        match self {
            Self::Npm => "npm",
            Self::Pnpm => "pnpm",
            Self::Yarn => "yarn",
            Self::Bun => "bun",
        }
    }

    fn add_runtime_package_command(self, package: &str) -> String {
        match self {
            Self::Npm => format!("npm install {package}"),
            Self::Pnpm => format!("pnpm add {package}"),
            Self::Yarn => format!("yarn add {package}"),
            Self::Bun => format!("bun add {package}"),
        }
    }

    const fn install_args(self) -> (&'static str, &'static [&'static str]) {
        match self {
            Self::Npm => ("npm", &["install", "--save-dev", "@fallow-cli/fallow-cov"]),
            Self::Pnpm => ("pnpm", &["add", "-D", "@fallow-cli/fallow-cov"]),
            Self::Yarn => ("yarn", &["add", "-D", "@fallow-cli/fallow-cov"]),
            Self::Bun => ("bun", &["add", "-d", "@fallow-cli/fallow-cov"]),
        }
    }

    fn install_command(self) -> String {
        let (program, args) = self.install_args();
        format!("{program} {}", args.join(" "))
    }

    fn run_script(self, script: &str) -> String {
        match self {
            Self::Npm => format!("npm run {script}"),
            Self::Pnpm => format!("pnpm {script}"),
            Self::Yarn => format!("yarn {script}"),
            Self::Bun => format!("bun run {script}"),
        }
    }

    fn exec_binary(self, binary: &str, args: &[&str]) -> String {
        let suffix = if args.is_empty() {
            String::new()
        } else {
            format!(" {}", args.join(" "))
        };
        match self {
            Self::Npm => format!("npx {binary}{suffix}"),
            Self::Pnpm => format!("pnpm exec {binary}{suffix}"),
            Self::Yarn => format!("yarn {binary}{suffix}"),
            Self::Bun => format!("bunx {binary}{suffix}"),
        }
    }
}

#[derive(Debug, Clone)]
struct CoverageSetupContext {
    framework: FrameworkKind,
    package_manager: Option<PackageManager>,
    has_build_script: bool,
    has_start_script: bool,
    has_preview_script: bool,
    node_entry_path: String,
}

#[derive(Debug, Clone)]
struct CoverageSetupMember {
    name: String,
    root: PathBuf,
    context: CoverageSetupContext,
}

impl CoverageSetupContext {
    fn script_runner(&self) -> PackageManager {
        self.package_manager.unwrap_or(PackageManager::Npm)
    }

    fn build_command(&self) -> Option<String> {
        if self.has_build_script {
            return Some(self.script_runner().run_script("build"));
        }
        match self.framework {
            FrameworkKind::NextJs => Some(self.script_runner().exec_binary("next", &["build"])),
            FrameworkKind::Nuxt => Some(self.script_runner().exec_binary("nuxi", &["build"])),
            FrameworkKind::Astro => Some(self.script_runner().exec_binary("astro", &["build"])),
            FrameworkKind::Remix => Some(self.script_runner().exec_binary("remix", &["build"])),
            FrameworkKind::SvelteKit => Some(self.script_runner().exec_binary("vite", &["build"])),
            FrameworkKind::ViteBrowser => {
                Some(self.script_runner().exec_binary("vite", &["build"]))
            }
            FrameworkKind::NestJs | FrameworkKind::PlainNode | FrameworkKind::Other => None,
        }
    }

    fn run_command(&self) -> String {
        if self.has_preview_script
            && matches!(
                self.framework,
                FrameworkKind::Nuxt | FrameworkKind::SvelteKit | FrameworkKind::Astro
            )
        {
            return self.script_runner().run_script("preview");
        }
        if self.has_start_script {
            return self.script_runner().run_script("start");
        }
        match self.framework {
            FrameworkKind::NextJs => self.script_runner().exec_binary("next", &["start"]),
            FrameworkKind::Nuxt => self.script_runner().exec_binary("nuxi", &["preview"]),
            FrameworkKind::Astro => self.script_runner().exec_binary("astro", &["preview"]),
            FrameworkKind::SvelteKit | FrameworkKind::ViteBrowser => {
                self.script_runner().exec_binary("vite", &["preview"])
            }
            FrameworkKind::Remix => "node ./build/index.js".to_owned(),
            FrameworkKind::NestJs => "node dist/main.js".to_owned(),
            FrameworkKind::PlainNode | FrameworkKind::Other => "node dist/server.js".to_owned(),
        }
    }
}

/// Dispatch a `fallow coverage <sub>` invocation.
pub fn run(subcommand: CoverageSubcommand, ctx: &RunContext<'_>) -> ExitCode {
    match subcommand {
        CoverageSubcommand::Setup(args) => run_setup(args, ctx.root),
        CoverageSubcommand::Analyze(args) => analyze::run(&args, ctx),
        CoverageSubcommand::UploadInventory(args) => upload_inventory::run(&args, ctx.root),
        CoverageSubcommand::UploadSourceMaps(args) => upload_source_maps::run(&args, ctx.root),
    }
}

fn run_setup(args: SetupArgs, root: &Path) -> ExitCode {
    if args.json {
        return run_setup_json(root, args.explain);
    }

    println!("fallow coverage setup");
    println!();
    println!("What \"runtime coverage\" means: fallow looks at which functions actually");
    println!("ran in your deployed app, so it can say \"this code is never called\" with");
    println!("proof, not just \"this code has no static references.\"");
    println!();

    let key = match license::verifying_key() {
        Ok(key) => key,
        Err(message) => {
            eprintln!("fallow coverage setup: {message}");
            return ExitCode::from(2);
        }
    };

    let license_state = fallow_license::load_and_verify(&key, DEFAULT_HARD_FAIL_DAYS);
    if let Some(exit) = handle_license_step(root, args, &license_state) {
        return exit;
    }

    let context = detect_setup_context(root);

    if let Some(exit) = handle_sidecar_step(root, args, context.package_manager) {
        return exit;
    }

    let recipe_path = match write_recipe(root, &context) {
        Ok(path) => path,
        Err(message) => {
            eprintln!("fallow coverage setup: {message}");
            return ExitCode::from(2);
        }
    };

    if let Some(coverage_path) = detect_coverage_artifact(root) {
        println!(
            "Step 3/4: Coverage found at {}",
            display_relative(root, &coverage_path)
        );
        println!(
            "Step 4/4: Running fallow health --runtime-coverage {} ...",
            display_relative(root, &coverage_path)
        );
        let exit = run_health_analysis(root, &coverage_path);
        print_upload_inventory_hint();
        return exit;
    }

    println!("Step 3/4: Collecting coverage for your app.");
    println!("  -> Detected: {}.", context.framework.label());
    println!(
        "  -> Wrote {} with the {} recipe.",
        display_relative(root, &recipe_path),
        context.framework.label()
    );
    println!("  -> Run your app with the instrumentation on, then re-run this command.");
    print_upload_inventory_hint();
    ExitCode::SUCCESS
}

fn run_setup_json(root: &Path, explain: bool) -> ExitCode {
    let payload = build_setup_json(root, explain);
    let stdout = std::io::stdout();
    let mut handle = stdout.lock();
    if let Err(err) = serde_json::to_writer_pretty(&mut handle, &payload) {
        eprintln!("fallow coverage setup: failed to write JSON output: {err}");
        return ExitCode::from(2);
    }
    println!();
    ExitCode::SUCCESS
}

fn build_setup_json(root: &Path, explain: bool) -> serde_json::Value {
    let members = detect_setup_members(root);
    let primary_member = members.first();
    let fallback = detect_setup_context(root);
    let primary = primary_member.map_or_else(|| fallback.clone(), |member| member.context.clone());
    let package_manager = primary.script_runner();
    let primary_prefix = primary_member
        .map(|member| member_path_prefix(&display_member_path(root, &member.root)))
        .unwrap_or_default();
    let snippets = primary_member.map_or_else(Vec::new, |_| setup_snippets(&primary));
    let files_to_edit = snippets_to_files(&snippets, &primary_prefix);
    let snippet_values = snippets_to_json(&snippets, &primary_prefix);
    let runtime_targets = union_runtime_targets(members.iter().map(|member| &member.context));
    let member_values: Vec<serde_json::Value> = members
        .iter()
        .map(|member| setup_member_json(root, member))
        .collect();
    let mut warnings = primary_member.map_or_else(
        || setup_json_warnings(root, &fallback),
        |_| setup_json_warnings(root, &primary),
    );
    if primary_member.is_none() {
        warnings.push(
            "No runtime workspace members were detected; emitted install commands only.".to_owned(),
        );
    }

    let mut payload = serde_json::json!({
        "schema_version": "1",
        "framework_detected": primary_member.map_or("unknown", |_| primary.framework.id()),
        "package_manager": primary.package_manager.map(PackageManager::label),
        "runtime_targets": runtime_targets,
        "members": member_values,
        "config_written": null,
        "commands": [
            package_manager.add_runtime_package_command("@fallow-cli/beacon"),
            package_manager.install_command(),
        ],
        "files_to_edit": files_to_edit,
        "snippets": snippet_values,
        "dockerfile_snippet": primary_member
            .map_or(serde_json::Value::Null, |_| dockerfile_snippet(&primary)),
        "next_steps": [
            "Add the snippets to your application.",
            "Deploy with the beacon enabled.",
            "Run fallow health --runtime-coverage ./coverage --format json after collecting a local capture.",
            "Set FALLOW_API_KEY in CI before running fallow coverage upload-inventory."
        ],
        "warnings": warnings,
    });

    if explain && let Some(object) = payload.as_object_mut() {
        object.insert("_meta".to_owned(), crate::explain::coverage_setup_meta());
    }

    payload
}

struct SetupSnippet {
    label: &'static str,
    path: String,
    reason: &'static str,
    content: String,
}

fn setup_member_json(root: &Path, member: &CoverageSetupMember) -> serde_json::Value {
    let member_path = display_member_path(root, &member.root);
    let prefix = member_path_prefix(&member_path);
    let snippets = setup_snippets(&member.context);
    serde_json::json!({
        "name": member.name,
        "path": member_path,
        "framework_detected": member.context.framework.id(),
        "package_manager": member.context.package_manager.map(PackageManager::label),
        "runtime_targets": member.context.framework.runtime_targets(),
        "files_to_edit": snippets_to_files(&snippets, &prefix),
        "snippets": snippets_to_json(&snippets, &prefix),
        "dockerfile_snippet": dockerfile_snippet(&member.context),
        "warnings": setup_json_warnings(&member.root, &member.context),
    })
}

fn snippets_to_files(snippets: &[SetupSnippet], prefix: &str) -> Vec<serde_json::Value> {
    snippets
        .iter()
        .map(|snippet| {
            serde_json::json!({
                "path": prefixed_member_path(prefix, &snippet.path),
                "reason": snippet.reason,
            })
        })
        .collect()
}

fn snippets_to_json(snippets: &[SetupSnippet], prefix: &str) -> Vec<serde_json::Value> {
    snippets
        .iter()
        .map(|snippet| {
            serde_json::json!({
                "label": snippet.label,
                "path": prefixed_member_path(prefix, &snippet.path),
                "content": snippet.content,
            })
        })
        .collect()
}

const NEXT_INSTRUMENTATION_SNIPPET: &str = r#"export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    const { createNodeBeacon } = await import("@fallow-cli/beacon");
    const beacon = createNodeBeacon({
      apiKey: process.env.FALLOW_API_KEY,
      projectId: process.env.FALLOW_PROJECT_ID ?? "my-app",
      endpoint: process.env.FALLOW_API_URL ?? "https://api.fallow.cloud",
      transport: process.env.FALLOW_TRANSPORT === "fs" ? "fs" : "http",
      writeToDir: process.env.FALLOW_WRITE_TO_DIR,
    });
    beacon.start();
  }
}
"#;

const NODE_BEACON_SNIPPET: &str = r#"import { createNodeBeacon } from "@fallow-cli/beacon";

const fallowBeacon = createNodeBeacon({
  apiKey: process.env.FALLOW_API_KEY,
  projectId: process.env.FALLOW_PROJECT_ID ?? "my-app",
  endpoint: process.env.FALLOW_API_URL ?? "https://api.fallow.cloud",
  transport: process.env.FALLOW_TRANSPORT === "fs" ? "fs" : "http",
  writeToDir: process.env.FALLOW_WRITE_TO_DIR,
});

fallowBeacon.start();
"#;

const NUXT_SERVER_PLUGIN_SNIPPET: &str = r#"export default defineNitroPlugin(async () => {
  const { createNodeBeacon } = await import("@fallow-cli/beacon");
  const beacon = createNodeBeacon({
    apiKey: process.env.FALLOW_API_KEY,
    projectId: process.env.FALLOW_PROJECT_ID ?? "my-app",
    endpoint: process.env.FALLOW_API_URL ?? "https://api.fallow.cloud",
    transport: process.env.FALLOW_TRANSPORT === "fs" ? "fs" : "http",
    writeToDir: process.env.FALLOW_WRITE_TO_DIR,
  });
  beacon.start();
});
"#;

const BROWSER_BEACON_SNIPPET: &str = r#"import { createBrowserBeacon } from "@fallow-cli/beacon/browser";

const fallowBeacon = createBrowserBeacon({
  apiKey: import.meta.env.VITE_FALLOW_API_KEY,
  projectId: import.meta.env.VITE_FALLOW_PROJECT_ID ?? "my-app",
  endpoint: import.meta.env.VITE_FALLOW_API_URL ?? "https://api.fallow.cloud",
  sampleRate: 0.01,
});

fallowBeacon.start();
"#;

fn setup_snippet(
    label: &'static str,
    path: impl Into<String>,
    reason: &'static str,
    content: &'static str,
) -> SetupSnippet {
    SetupSnippet {
        label,
        path: path.into(),
        reason,
        content: content.to_owned(),
    }
}

fn setup_snippets(context: &CoverageSetupContext) -> Vec<SetupSnippet> {
    match context.framework {
        FrameworkKind::NextJs => vec![setup_snippet(
            "Next.js instrumentation",
            "instrumentation.ts",
            "Initialize the Node runtime beacon during Next.js startup.",
            NEXT_INSTRUMENTATION_SNIPPET,
        )],
        FrameworkKind::NestJs => vec![setup_snippet(
            "NestJS bootstrap",
            "src/main.ts",
            "Start the Node runtime beacon before creating the Nest app.",
            NODE_BEACON_SNIPPET,
        )],
        FrameworkKind::Nuxt => vec![setup_snippet(
            "Nuxt server plugin",
            "server/plugins/fallow.ts",
            "Start the Node runtime beacon when the Nuxt server boots.",
            NUXT_SERVER_PLUGIN_SNIPPET,
        )],
        FrameworkKind::SvelteKit => vec![setup_snippet(
            "SvelteKit server hook",
            "src/hooks.server.ts",
            "Start the Node runtime beacon before handling server requests.",
            NODE_BEACON_SNIPPET,
        )],
        FrameworkKind::Astro => vec![setup_snippet(
            "Astro middleware",
            "src/middleware.ts",
            "Start the Node runtime beacon from the server middleware module.",
            NODE_BEACON_SNIPPET,
        )],
        FrameworkKind::Remix => vec![setup_snippet(
            "Remix server entry",
            "app/entry.server.tsx",
            "Start the Node runtime beacon from the server entry module.",
            NODE_BEACON_SNIPPET,
        )],
        FrameworkKind::ViteBrowser => vec![setup_snippet(
            "Vite browser entry",
            "src/main.ts",
            "Start the browser runtime beacon from the client entry module.",
            BROWSER_BEACON_SNIPPET,
        )],
        FrameworkKind::PlainNode | FrameworkKind::Other => vec![setup_snippet(
            "Node entrypoint",
            context.node_entry_path.clone(),
            "Start the Node runtime beacon before application code handles traffic.",
            NODE_BEACON_SNIPPET,
        )],
    }
}

fn dockerfile_snippet(context: &CoverageSetupContext) -> serde_json::Value {
    if context.framework.runtime_targets().contains(&"node") {
        serde_json::json!("ENV FALLOW_TRANSPORT=fs\nENV FALLOW_WRITE_TO_DIR=/tmp/fallow-coverage")
    } else {
        serde_json::Value::Null
    }
}

fn setup_json_warnings(root: &Path, context: &CoverageSetupContext) -> Vec<String> {
    let mut warnings = Vec::new();
    if context.framework == FrameworkKind::Other {
        warnings.push(
            "Framework was not detected; emitted the plain Node fallback snippet.".to_owned(),
        );
    }
    if context.package_manager.is_none() {
        warnings.push("Package manager was not detected; npm commands were emitted.".to_owned());
    }
    if detect_coverage_artifact(root).is_none() {
        warnings.push("No local coverage artifact was detected yet.".to_owned());
    }
    warnings
}

/// Nudge the user toward `fallow coverage upload-inventory`. The runtime
/// beacon gives the dashboard `called` / `never_called`; the static inventory
/// upload gives it `untracked` (functions that exist but runtime never parsed).
/// Without this hint, trial users finish setup with no signal that the
/// dashboard's Untracked filter needs a second CI step to light up.
fn print_upload_inventory_hint() {
    println!();
    println!("Next, in CI, upload the static function inventory so the dashboard's");
    println!("Untracked filter lights up:");
    println!("  fallow coverage upload-inventory");
    println!("Set FALLOW_API_KEY on the runner. See {COVERAGE_DOCS_URL} for the full CI snippet.");
}

fn handle_license_step(
    root: &Path,
    args: SetupArgs,
    license_state: &Result<LicenseStatus, fallow_license::LicenseError>,
) -> Option<ExitCode> {
    match license_state {
        Ok(
            LicenseStatus::Valid { .. }
            | LicenseStatus::ExpiredWarning { .. }
            | LicenseStatus::ExpiredWatermark { .. },
        ) => {
            println!("Step 1/4: License check... ok.");
            None
        }
        Ok(LicenseStatus::Missing) => {
            println!("Step 1/4: License check... none found.");
            offer_trial_if_needed(root, args)
        }
        Ok(LicenseStatus::HardFail {
            days_since_expiry, ..
        }) => {
            println!("Step 1/4: License check... expired {days_since_expiry} days ago.");
            offer_trial_if_needed(root, args)
        }
        Err(err) => {
            println!("Step 1/4: License check... existing token is invalid ({err}).");
            offer_trial_if_needed(root, args)
        }
    }
}

fn offer_trial_if_needed(root: &Path, args: SetupArgs) -> Option<ExitCode> {
    println!("  -> Single local captures work without a license.");
    let prompt = "  -> Start a 30-day trial for continuous/multi-capture monitoring? [y/N] ";
    let accepted = match confirm_default_no(prompt, args) {
        Ok(accepted) => accepted,
        Err(message) => {
            eprintln!("fallow coverage setup: {message}");
            return Some(ExitCode::from(2));
        }
    };
    if !accepted {
        println!(
            "  -> For continuous monitoring, run: fallow license activate --trial --email you@company.com"
        );
        return None;
    }

    let email = match prompt_email(args) {
        Ok(Some(email)) => email,
        Ok(None) => return None,
        Err(message) => {
            eprintln!("fallow coverage setup: {message}");
            return Some(ExitCode::from(2));
        }
    };

    match license::activate_trial(&email) {
        Ok(status) => {
            println!(
                "  -> This license is machine-scoped (stored at {}).",
                default_license_display(root)
            );
            println!("     Your teammates each start their own trial.");
            print_trial_status(&status);
            None
        }
        Err(message) => {
            eprintln!("fallow coverage setup: {message}");
            Some(ExitCode::from(7))
        }
    }
}

fn handle_sidecar_step(
    root: &Path,
    args: SetupArgs,
    package_manager: Option<PackageManager>,
) -> Option<ExitCode> {
    match runtime_coverage::discover_sidecar(Some(root)) {
        Ok(path) => {
            println!("Step 2/4: Sidecar check... ok ({})", path.to_string_lossy());
            None
        }
        Err(message) => {
            println!("Step 2/4: Sidecar check... not installed.");
            println!("  -> {message}");
            let install_command = package_manager.map_or_else(
                || "npm install -g @fallow-cli/fallow-cov".to_owned(),
                PackageManager::install_command,
            );
            let prompt = if let Some(package_manager) = package_manager {
                format!(
                    "  -> Install @fallow-cli/fallow-cov with {}? [Y/n] ",
                    package_manager.label()
                )
            } else {
                "  -> Install @fallow-cli/fallow-cov globally via npm? [Y/n] ".to_owned()
            };
            let accepted = match confirm(prompt, args) {
                Ok(accepted) => accepted,
                Err(message) => {
                    eprintln!("fallow coverage setup: {message}");
                    return Some(ExitCode::from(2));
                }
            };
            if !accepted {
                println!("  -> Run: {install_command}");
                println!(
                    "  -> Manual fallback: install a signed binary and place it at {}",
                    runtime_coverage::canonical_sidecar_path().display()
                );
                return Some(ExitCode::SUCCESS);
            }

            match install_sidecar(root, package_manager) {
                Ok(path) => {
                    println!("  -> Installed at {}", path.display());
                    None
                }
                Err(message) => {
                    eprintln!("fallow coverage setup: {message}");
                    Some(ExitCode::from(4))
                }
            }
        }
    }
}

fn confirm_default_no(prompt: impl AsRef<str>, args: SetupArgs) -> Result<bool, String> {
    confirm_with_default(prompt, args, false)
}

fn confirm(prompt: impl AsRef<str>, args: SetupArgs) -> Result<bool, String> {
    confirm_with_default(prompt, args, true)
}

fn confirm_with_default(
    prompt: impl AsRef<str>,
    args: SetupArgs,
    default: bool,
) -> Result<bool, String> {
    let prompt = prompt.as_ref();
    if args.non_interactive {
        println!("{prompt}skipped (--non-interactive)");
        return Ok(false);
    }
    if args.yes {
        println!("{prompt}Y");
        return Ok(true);
    }

    print!("{prompt}");
    io::stdout()
        .flush()
        .map_err(|err| format!("failed to flush stdout: {err}"))?;

    let mut answer = String::new();
    io::stdin()
        .read_line(&mut answer)
        .map_err(|err| format!("failed to read stdin: {err}"))?;
    let trimmed = answer.trim().to_ascii_lowercase();
    Ok(if trimmed.is_empty() {
        default
    } else {
        trimmed == "y" || trimmed == "yes"
    })
}

fn prompt_email(args: SetupArgs) -> Result<Option<String>, String> {
    if args.non_interactive {
        println!("  -> Run: fallow license activate --trial --email you@company.com");
        return Ok(None);
    }
    if args.yes {
        let Some(email) = default_trial_email() else {
            println!(
                "  -> Unable to infer an email address for --yes. Run: fallow license activate --trial --email <addr>"
            );
            return Ok(None);
        };
        println!("  -> Email: {email}");
        return Ok(Some(email));
    }

    print!("  -> Email: ");
    io::stdout()
        .flush()
        .map_err(|err| format!("failed to flush stdout: {err}"))?;

    let mut answer = String::new();
    io::stdin()
        .read_line(&mut answer)
        .map_err(|err| format!("failed to read stdin: {err}"))?;
    let trimmed = answer.trim();
    if trimmed.is_empty() {
        return Err("email is required to start a trial".to_owned());
    }
    Ok(Some(trimmed.to_owned()))
}

fn default_trial_email() -> Option<String> {
    std::env::var("EMAIL")
        .ok()
        .filter(|value| !value.trim().is_empty())
        .or_else(git_config_email)
}

fn git_config_email() -> Option<String> {
    let mut command = Command::new("git");
    command.args(["config", "user.email"]);
    clear_ambient_git_env(&mut command);
    let output = command.output().ok()?;
    if !output.status.success() {
        return None;
    }
    let email = String::from_utf8(output.stdout).ok()?;
    let trimmed = email.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_owned())
    }
}

fn print_trial_status(status: &LicenseStatus) {
    match status {
        LicenseStatus::Valid {
            days_until_expiry, ..
        } => {
            println!("  -> Trial active. {days_until_expiry} days remaining.");
        }
        LicenseStatus::ExpiredWarning {
            days_since_expiry, ..
        }
        | LicenseStatus::ExpiredWatermark {
            days_since_expiry, ..
        }
        | LicenseStatus::HardFail {
            days_since_expiry, ..
        } => {
            println!(
                "  -> Trial activated, but it is already expired by {days_since_expiry} days."
            );
        }
        LicenseStatus::Missing => {
            println!("  -> Trial request completed, but no license was stored.");
        }
    }
}

fn default_license_display(root: &Path) -> String {
    display_relative(root, &fallow_license::default_license_path())
}

fn install_sidecar(
    root: &Path,
    package_manager: Option<PackageManager>,
) -> Result<PathBuf, String> {
    let (program, args, current_dir, display_command) =
        if let Some(package_manager) = package_manager {
            let (program, args) = package_manager.install_args();
            (program, args, root, package_manager.install_command())
        } else {
            (
                "npm",
                &["install", "-g", "@fallow-cli/fallow-cov"][..],
                root,
                "npm install -g @fallow-cli/fallow-cov".to_owned(),
            )
        };

    let status = Command::new(program)
        .args(args)
        .current_dir(current_dir)
        .status()
        .map_err(|err| format!("failed to run {display_command}: {err}"))?;

    if !status.success() {
        return Err(format!(
            "{display_command} failed. Install it manually or place the binary in {}",
            runtime_coverage::canonical_sidecar_path().display()
        ));
    }

    runtime_coverage::discover_sidecar(Some(root)).map_err(|_| {
        format!(
            "sidecar install finished but fallow still could not find fallow-cov. Checked project-local node_modules/.bin, {}, and PATH",
            runtime_coverage::canonical_sidecar_path().display()
        )
    })
}

fn detect_setup_context(root: &Path) -> CoverageSetupContext {
    let package_json = PackageJson::load(&root.join("package.json")).ok();
    let framework = detect_framework(package_json.as_ref());
    let package_manager = detect_package_manager(root);
    let scripts = package_json.as_ref().and_then(|pkg| pkg.scripts.as_ref());
    CoverageSetupContext {
        framework,
        package_manager,
        has_build_script: scripts.is_some_and(|scripts| scripts.contains_key("build")),
        has_start_script: scripts.is_some_and(|scripts| scripts.contains_key("start")),
        has_preview_script: scripts.is_some_and(|scripts| scripts.contains_key("preview")),
        node_entry_path: detect_node_entry_path(root, package_json.as_ref()),
    }
}

fn detect_setup_members(root: &Path) -> Vec<CoverageSetupMember> {
    let root_package_manager = detect_package_manager(root);
    let root_package_json = PackageJson::load(&root.join("package.json")).ok();
    let mut workspaces = discover_workspaces(root);
    workspaces.sort_by(|a, b| a.root.cmp(&b.root));
    let has_workspaces = !workspaces.is_empty();
    let mut members = Vec::new();

    if should_include_root_setup_member(root_package_json.as_ref(), has_workspaces, root) {
        members.push(CoverageSetupMember {
            name: root_package_json
                .as_ref()
                .and_then(|package_json| package_json.name.clone())
                .unwrap_or_else(|| "(root)".to_owned()),
            root: root.to_path_buf(),
            context: detect_setup_context_with_package_manager(root, root_package_manager),
        });
    }

    members.extend(workspaces.into_iter().filter_map(|workspace| {
        setup_member_from_workspace(root, workspace, root_package_manager)
    }));
    members
}

fn setup_member_from_workspace(
    project_root: &Path,
    workspace: WorkspaceInfo,
    root_package_manager: Option<PackageManager>,
) -> Option<CoverageSetupMember> {
    if same_path(project_root, &workspace.root) {
        return None;
    }
    let package_json = PackageJson::load(&workspace.root.join("package.json")).ok()?;
    if !is_runtime_workspace_package(&workspace.root, &package_json) {
        return None;
    }
    Some(CoverageSetupMember {
        name: workspace.name,
        context: detect_setup_context_with_package_manager(&workspace.root, root_package_manager),
        root: workspace.root,
    })
}

fn detect_setup_context_with_package_manager(
    root: &Path,
    fallback_package_manager: Option<PackageManager>,
) -> CoverageSetupContext {
    let package_json = PackageJson::load(&root.join("package.json")).ok();
    let framework = detect_framework(package_json.as_ref());
    let package_manager = detect_package_manager(root).or(fallback_package_manager);
    let scripts = package_json.as_ref().and_then(|pkg| pkg.scripts.as_ref());
    CoverageSetupContext {
        framework,
        package_manager,
        has_build_script: scripts.is_some_and(|scripts| scripts.contains_key("build")),
        has_start_script: scripts.is_some_and(|scripts| scripts.contains_key("start")),
        has_preview_script: scripts.is_some_and(|scripts| scripts.contains_key("preview")),
        node_entry_path: detect_node_entry_path(root, package_json.as_ref()),
    }
}

fn detect_framework(package_json: Option<&PackageJson>) -> FrameworkKind {
    let Some(package_json) = package_json else {
        return FrameworkKind::Other;
    };
    let dependencies = package_json.all_dependency_names();
    if dependencies.iter().any(|name| name == "next") {
        FrameworkKind::NextJs
    } else if dependencies.iter().any(|name| name.starts_with("@nestjs/")) {
        FrameworkKind::NestJs
    } else if dependencies
        .iter()
        .any(|name| name == "nuxt" || name == "nuxi")
    {
        FrameworkKind::Nuxt
    } else if dependencies.iter().any(|name| name == "@sveltejs/kit") {
        FrameworkKind::SvelteKit
    } else if dependencies.iter().any(|name| name == "astro") {
        FrameworkKind::Astro
    } else if dependencies
        .iter()
        .any(|name| name == "remix" || name.starts_with("@remix-run/"))
    {
        FrameworkKind::Remix
    } else if dependencies
        .iter()
        .any(|name| is_node_server_framework(name))
    {
        FrameworkKind::PlainNode
    } else if dependencies.iter().any(|name| name == "vite") {
        FrameworkKind::ViteBrowser
    } else if package_json.name.is_some() {
        FrameworkKind::PlainNode
    } else {
        FrameworkKind::Other
    }
}

fn is_node_server_framework(name: &str) -> bool {
    matches!(
        name,
        "elysia" | "express" | "fastify" | "hono" | "koa" | "@koa/router" | "@trpc/server"
    )
}

fn should_include_root_setup_member(
    package_json: Option<&PackageJson>,
    has_workspaces: bool,
    root: &Path,
) -> bool {
    if !has_workspaces {
        return true;
    }

    package_json.is_some_and(|package_json| is_runtime_workspace_package(root, package_json))
}

fn is_runtime_workspace_package(root: &Path, package_json: &PackageJson) -> bool {
    match detect_framework(Some(package_json)) {
        FrameworkKind::ViteBrowser => is_vite_browser_app(root, package_json),
        FrameworkKind::PlainNode | FrameworkKind::Other => {
            has_node_server_dependency(package_json) || has_runtime_script(package_json)
        }
        FrameworkKind::NextJs
        | FrameworkKind::NestJs
        | FrameworkKind::Nuxt
        | FrameworkKind::SvelteKit
        | FrameworkKind::Astro
        | FrameworkKind::Remix => true,
    }
}

fn has_node_server_dependency(package_json: &PackageJson) -> bool {
    package_json
        .all_dependency_names()
        .iter()
        .any(|name| is_node_server_framework(name))
}

fn has_runtime_script(package_json: &PackageJson) -> bool {
    package_json.scripts.as_ref().is_some_and(|scripts| {
        scripts.contains_key("start")
            || scripts.contains_key("preview")
            || scripts.contains_key("dev")
    })
}

fn is_vite_browser_app(root: &Path, package_json: &PackageJson) -> bool {
    let has_vite_dependency = package_json
        .all_dependency_names()
        .iter()
        .any(|name| name == "vite");
    if !has_vite_dependency {
        return false;
    }

    package_json.scripts.as_ref().is_some_and(|scripts| {
        ["dev", "preview"]
            .iter()
            .filter_map(|script_name| scripts.get(*script_name))
            .any(|script| script_invokes_vite_app(script))
    }) || [
        "index.html",
        "src/main.ts",
        "src/main.tsx",
        "src/main.js",
        "src/main.jsx",
        "src/main.mts",
        "src/main.mjs",
    ]
    .iter()
    .any(|candidate| root.join(candidate).is_file())
}

fn script_invokes_vite_app(script: &str) -> bool {
    script
        .split(|character: char| {
            character.is_whitespace()
                || matches!(character, '"' | '\'' | ':' | ';' | '&' | '|' | '(' | ')')
        })
        .any(|token| matches!(token, "vite" | "vite-preview" | "vite-plus" | "vp"))
}

fn detect_node_entry_path(root: &Path, package_json: Option<&PackageJson>) -> String {
    for candidate in [
        "src/index.ts",
        "src/server.ts",
        "src/main.ts",
        "src/app.ts",
        "index.ts",
        "server.ts",
    ] {
        if root.join(candidate).is_file() {
            return candidate.to_owned();
        }
    }

    if let Some(package_json) = package_json {
        for entry in package_json.entry_points() {
            let normalized = entry.trim_start_matches("./");
            if root.join(normalized).is_file() {
                return path_to_json_string(Path::new(normalized));
            }
        }
        if package_json
            .all_dependency_names()
            .iter()
            .any(|name| is_node_server_framework(name))
        {
            return "src/index.ts".to_owned();
        }
    }

    "src/server.ts".to_owned()
}

fn union_runtime_targets<'a>(
    contexts: impl IntoIterator<Item = &'a CoverageSetupContext>,
) -> Vec<&'static str> {
    let mut has_node = false;
    let mut has_browser = false;
    for context in contexts {
        for target in context.framework.runtime_targets() {
            match *target {
                "node" => has_node = true,
                "browser" => has_browser = true,
                _ => {}
            }
        }
    }

    let mut targets = Vec::new();
    if has_node {
        targets.push("node");
    }
    if has_browser {
        targets.push("browser");
    }
    targets
}

fn same_path(left: &Path, right: &Path) -> bool {
    let left = dunce::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
    let right = dunce::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
    left == right
}

fn detect_package_manager(root: &Path) -> Option<PackageManager> {
    detect_package_manager_from_field(root).or_else(|| {
        if root.join("bun.lockb").exists() || root.join("bun.lock").exists() {
            Some(PackageManager::Bun)
        } else if root.join("pnpm-lock.yaml").exists() {
            Some(PackageManager::Pnpm)
        } else if root.join("yarn.lock").exists() {
            Some(PackageManager::Yarn)
        } else if root.join("package-lock.json").exists()
            || root.join("npm-shrinkwrap.json").exists()
        {
            Some(PackageManager::Npm)
        } else {
            None
        }
    })
}

fn detect_package_manager_from_field(root: &Path) -> Option<PackageManager> {
    let content = std::fs::read_to_string(root.join("package.json")).ok()?;
    let value: serde_json::Value = serde_json::from_str(&content).ok()?;
    let field = value.get("packageManager")?.as_str()?;
    let name = field.split('@').next().unwrap_or(field);
    match name {
        "npm" => Some(PackageManager::Npm),
        "pnpm" => Some(PackageManager::Pnpm),
        "yarn" => Some(PackageManager::Yarn),
        "bun" => Some(PackageManager::Bun),
        _ => None,
    }
}

fn write_recipe(root: &Path, context: &CoverageSetupContext) -> Result<PathBuf, String> {
    let docs_dir = root.join("docs");
    std::fs::create_dir_all(&docs_dir)
        .map_err(|err| format!("failed to create {}: {err}", docs_dir.display()))?;
    let path = docs_dir.join("collect-coverage.md");
    std::fs::write(&path, recipe_contents(context))
        .map_err(|err| format!("failed to write {}: {err}", path.display()))?;
    Ok(path)
}

fn recipe_contents(context: &CoverageSetupContext) -> String {
    let title = match context.framework {
        FrameworkKind::NextJs => "Next.js",
        FrameworkKind::NestJs => "NestJS",
        FrameworkKind::Nuxt => "Nuxt",
        FrameworkKind::SvelteKit => "SvelteKit",
        FrameworkKind::Astro => "Astro",
        FrameworkKind::Remix => "Remix",
        FrameworkKind::ViteBrowser => "Vite",
        FrameworkKind::PlainNode => "Node service",
        FrameworkKind::Other => {
            return format!(
                "# Collect runtime coverage\n\nThis project was not matched to a built-in recipe.\nSee {COVERAGE_DOCS_URL} for framework-specific instructions.\n"
            );
        }
    };

    let mut lines = vec![
        format!("# Collect runtime coverage for {title}"),
        String::new(),
    ];
    lines.push("1. Remove any old dump directory: `rm -rf ./coverage`".to_owned());
    let final_step = if context.has_build_script || context.build_command().is_some() {
        if let Some(build_command) = context.build_command() {
            lines.push(format!("2. Build the app: `{build_command}`"));
        }
        lines.push(format!(
            "3. Start the app with V8 coverage enabled: `NODE_V8_COVERAGE=./coverage {}`",
            context.run_command()
        ));
        lines.push("4. Exercise the routes or jobs you care about.".to_owned());
        lines.push("5. Stop the app and run: `fallow coverage setup`".to_owned());
        "6"
    } else {
        lines.push(format!(
            "2. Start the app with V8 coverage enabled: `NODE_V8_COVERAGE=./coverage {}`",
            context.run_command()
        ));
        lines.push("3. Exercise the app traffic you want to analyze.".to_owned());
        lines.push("4. Stop the process and run: `fallow coverage setup`".to_owned());
        "5"
    };
    lines.push(format!(
        "{final_step}. In CI, after the build, run \
         `fallow coverage upload-inventory` with `FALLOW_API_KEY` set. The \
         upload is what enables the dashboard's Untracked filter (functions \
         that exist but runtime coverage never parsed). Runtime coverage alone \
         only answers `called` vs `never_called`; the static inventory adds \
         the third state."
    ));
    lines.push(String::new());
    lines.join("\n")
}

fn detect_coverage_artifact(root: &Path) -> Option<PathBuf> {
    for file in [
        root.join("coverage/coverage-final.json"),
        root.join(".nyc_output/coverage-final.json"),
    ] {
        if file.is_file() {
            return Some(file);
        }
    }

    [root.join("coverage"), root.join(".nyc_output")]
        .into_iter()
        .find(|dir| dir.is_dir() && directory_has_json(dir))
}

fn directory_has_json(path: &Path) -> bool {
    let Ok(entries) = std::fs::read_dir(path) else {
        return false;
    };

    entries
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .any(|entry| entry.extension() == Some(OsStr::new("json")))
}

fn run_health_analysis(root: &Path, coverage_path: &Path) -> ExitCode {
    let current_exe = match std::env::current_exe() {
        Ok(path) => path,
        Err(err) => {
            eprintln!("fallow coverage setup: failed to resolve current executable: {err}");
            return ExitCode::from(2);
        }
    };

    let status = match Command::new(current_exe)
        .arg("health")
        .arg("--root")
        .arg(root)
        .arg("--runtime-coverage")
        .arg(coverage_path)
        .status()
    {
        Ok(status) => status,
        Err(err) => {
            eprintln!("fallow coverage setup: failed to run health analysis: {err}");
            return ExitCode::from(2);
        }
    };

    match status.code() {
        Some(code) => ExitCode::from(u8::try_from(code).unwrap_or(2)),
        None => ExitCode::from(2),
    }
}

fn display_relative(root: &Path, path: &Path) -> String {
    path.strip_prefix(root).map_or_else(
        |_| path.to_string_lossy().into_owned(),
        |relative| format!("./{}", relative.to_string_lossy()),
    )
}

fn display_member_path(root: &Path, path: &Path) -> String {
    let relative = path.strip_prefix(root).unwrap_or(path);
    if relative.as_os_str().is_empty() {
        ".".to_owned()
    } else {
        path_to_json_string(relative)
    }
}

fn member_path_prefix(member_path: &str) -> String {
    if member_path == "." {
        String::new()
    } else {
        member_path.to_owned()
    }
}

fn prefixed_member_path(prefix: &str, path: &str) -> String {
    if prefix.is_empty() {
        path.to_owned()
    } else {
        format!("{prefix}/{path}")
    }
}

fn path_to_json_string(path: &Path) -> String {
    path.to_string_lossy().replace('\\', "/")
}

#[cfg(test)]
mod tests {
    use super::{
        CoverageSetupContext, FrameworkKind, PackageManager, SetupArgs, build_setup_json,
        detect_coverage_artifact, detect_framework, detect_package_manager, handle_license_step,
        recipe_contents,
    };
    use fallow_config::PackageJson;
    use fallow_license::LicenseStatus;
    use tempfile::tempdir;

    #[test]
    fn setup_continues_without_license_for_single_local_capture() {
        let dir = tempdir().expect("tempdir should be created");
        let args = SetupArgs {
            non_interactive: true,
            ..SetupArgs::default()
        };
        let status = Ok(LicenseStatus::Missing);

        assert!(
            handle_license_step(dir.path(), args, &status).is_none(),
            "missing license must not stop setup; single local captures are free"
        );
    }

    #[test]
    fn detect_framework_recognizes_nuxt_projects() {
        let package_json: PackageJson =
            serde_json::from_str(r#"{"name":"demo","dependencies":{"nuxt":"^3.0.0"}}"#)
                .expect("package.json should parse");

        assert_eq!(detect_framework(Some(&package_json)), FrameworkKind::Nuxt);
    }

    #[test]
    fn detect_framework_recognizes_vite_browser_projects() {
        let package_json: PackageJson =
            serde_json::from_str(r#"{"name":"demo","devDependencies":{"vite":"^6.0.0"}}"#)
                .expect("package.json should parse");

        assert_eq!(
            detect_framework(Some(&package_json)),
            FrameworkKind::ViteBrowser
        );
    }

    #[test]
    fn detect_framework_prefers_node_server_frameworks_over_vite() {
        let package_json: PackageJson = serde_json::from_str(
            r#"{"name":"api","dependencies":{"elysia":"^1.0.0"},"devDependencies":{"vite":"^6.0.0"}}"#,
        )
        .expect("package.json should parse");

        assert_eq!(
            detect_framework(Some(&package_json)),
            FrameworkKind::PlainNode
        );
    }

    #[test]
    fn detect_package_manager_prefers_package_manager_field() {
        let dir = tempdir().expect("tempdir should be created");
        std::fs::write(
            dir.path().join("package.json"),
            r#"{"name":"demo","packageManager":"bun@1.2.0"}"#,
        )
        .expect("package.json should be written");
        std::fs::write(dir.path().join("pnpm-lock.yaml"), "lockfileVersion: '9.0'")
            .expect("lockfile should be written");

        assert_eq!(
            detect_package_manager(dir.path()),
            Some(PackageManager::Bun)
        );
    }

    #[test]
    fn setup_json_emits_workspace_members_and_union_runtime_targets() {
        let dir = tempdir().expect("tempdir should be created");
        std::fs::write(
            dir.path().join("package.json"),
            r#"{
  "name": "@demo/api",
  "private": true,
  "packageManager": "pnpm@9.0.0",
  "workspaces": ["apps/*", "packages/*"],
  "scripts": { "start": "node dist/server.js" },
  "dependencies": { "elysia": "^1.0.0" },
  "devDependencies": { "vite": "^6.0.0" }
}"#,
        )
        .expect("root package.json should be written");
        std::fs::create_dir_all(dir.path().join("src")).expect("root src dir should be created");
        std::fs::write(
            dir.path().join("src/index.ts"),
            "export const api = true;\n",
        )
        .expect("root entry should be written");

        for (path, name) in [
            ("apps/admin", "@demo/admin"),
            ("apps/marketing", "@demo/marketing"),
        ] {
            let workspace_dir = dir.path().join(path);
            std::fs::create_dir_all(&workspace_dir).expect("workspace dir should be created");
            std::fs::write(
                workspace_dir.join("package.json"),
                format!(
                    r#"{{"name":"{name}","scripts":{{"dev":"vite","build":"vite build"}},"devDependencies":{{"vite":"^6.0.0"}}}}"#
                ),
            )
            .expect("workspace package.json should be written");
        }
        let library_dir = dir.path().join("packages/shared");
        std::fs::create_dir_all(&library_dir).expect("library dir should be created");
        std::fs::write(
            library_dir.join("package.json"),
            r#"{"name":"@demo/shared","scripts":{"build":"tsc"},"devDependencies":{"typescript":"^6.0.0"}}"#,
        )
        .expect("library package.json should be written");

        let payload = build_setup_json(dir.path(), false);

        assert_eq!(payload["framework_detected"], "plain_node");
        assert_eq!(
            payload["runtime_targets"],
            serde_json::json!(["node", "browser"])
        );
        assert_eq!(payload["files_to_edit"][0]["path"], "src/index.ts");
        assert_eq!(
            payload["members"].as_array().map(Vec::len),
            Some(3),
            "root plus two workspace members should be emitted: {payload:#}"
        );

        let members = payload["members"].as_array().expect("members array");
        assert!(
            members
                .iter()
                .all(|member| member["path"] != "packages/shared"),
            "build-only library workspaces should not receive runtime setup recipes: {payload:#}"
        );
        let root_member = members
            .iter()
            .find(|member| member["path"] == ".")
            .expect("root member should be present");
        assert_eq!(root_member["name"], "@demo/api");
        assert_eq!(root_member["framework_detected"], "plain_node");
        assert_eq!(root_member["runtime_targets"], serde_json::json!(["node"]));
        assert_eq!(root_member["snippets"][0]["path"], "src/index.ts");

        for path in ["apps/admin", "apps/marketing"] {
            let member = members
                .iter()
                .find(|member| member["path"] == path)
                .unwrap_or_else(|| panic!("missing workspace member {path}: {payload:#}"));
            assert_eq!(member["framework_detected"], "vite");
            assert_eq!(member["package_manager"], "pnpm");
            assert_eq!(member["runtime_targets"], serde_json::json!(["browser"]));
            assert_eq!(member["snippets"][0]["path"], format!("{path}/src/main.ts"));
        }
    }

    /// Snapshot test that locks the full `coverage setup --json` workspace
    /// shape. Adding a new field to the payload (e.g. a future `_meta` block)
    /// is an intentional contract change, so the snapshot must be reviewed
    /// with `cargo insta review`. Reverting the workspace-aware fix or the
    /// Vite-app heuristic regenerates a different snapshot.
    #[test]
    fn setup_json_workspace_payload_matches_snapshot() {
        let dir = tempdir().expect("tempdir should be created");
        std::fs::write(
            dir.path().join("package.json"),
            r#"{
  "name": "@demo/api",
  "private": true,
  "packageManager": "pnpm@9.0.0",
  "workspaces": ["apps/*", "packages/*"],
  "scripts": { "start": "node dist/server.js" },
  "dependencies": { "elysia": "^1.0.0" },
  "devDependencies": { "vite": "^6.0.0" }
}"#,
        )
        .expect("root package.json should be written");
        std::fs::create_dir_all(dir.path().join("src")).expect("root src dir should be created");
        std::fs::write(
            dir.path().join("src/index.ts"),
            "export const api = true;\n",
        )
        .expect("root entry should be written");

        for (path, name) in [
            ("apps/admin", "@demo/admin"),
            ("apps/marketing", "@demo/marketing"),
        ] {
            let workspace_dir = dir.path().join(path);
            std::fs::create_dir_all(workspace_dir.join("src"))
                .expect("workspace src dir should be created");
            std::fs::write(
                workspace_dir.join("package.json"),
                format!(
                    r#"{{"name":"{name}","scripts":{{"dev":"vite","build":"vite build"}},"devDependencies":{{"vite":"^6.0.0"}}}}"#
                ),
            )
            .expect("workspace package.json should be written");
            std::fs::write(
                workspace_dir.join("src/main.ts"),
                "export const app = true;\n",
            )
            .expect("workspace entry should be written");
        }
        let library_dir = dir.path().join("packages/shared");
        std::fs::create_dir_all(&library_dir).expect("library dir should be created");
        std::fs::write(
            library_dir.join("package.json"),
            r#"{"name":"@demo/shared","scripts":{"build":"tsc"},"devDependencies":{"typescript":"^6.0.0"}}"#,
        )
        .expect("library package.json should be written");

        let payload = build_setup_json(dir.path(), false);

        insta::assert_yaml_snapshot!("coverage_setup_json_workspace", payload);
    }

    #[test]
    fn setup_json_skips_non_runtime_workspace_root() {
        let dir = tempdir().expect("tempdir should be created");
        std::fs::write(
            dir.path().join("package.json"),
            r#"{
  "name": "repo",
  "private": true,
  "packageManager": "pnpm@9.0.0",
  "workspaces": ["apps/*"]
}"#,
        )
        .expect("root package.json should be written");

        let api_dir = dir.path().join("apps/api");
        std::fs::create_dir_all(api_dir.join("src")).expect("api src dir should be created");
        std::fs::write(
            api_dir.join("package.json"),
            r#"{"name":"api","dependencies":{"elysia":"^1.0.0"}}"#,
        )
        .expect("api package.json should be written");
        std::fs::write(api_dir.join("src/index.ts"), "export const api = true;\n")
            .expect("api entry should be written");

        let web_dir = dir.path().join("apps/web");
        std::fs::create_dir_all(web_dir.join("src")).expect("web src dir should be created");
        std::fs::write(
            web_dir.join("package.json"),
            r#"{"name":"web","scripts":{"dev":"vite"},"devDependencies":{"vite":"^6.0.0"}}"#,
        )
        .expect("web package.json should be written");
        std::fs::write(web_dir.join("src/main.ts"), "export const web = true;\n")
            .expect("web entry should be written");

        let payload = build_setup_json(dir.path(), false);

        assert_eq!(payload["framework_detected"], "plain_node");
        assert_eq!(
            payload["runtime_targets"],
            serde_json::json!(["node", "browser"])
        );
        assert_eq!(payload["files_to_edit"][0]["path"], "apps/api/src/index.ts");

        let members = payload["members"].as_array().expect("members array");
        assert_eq!(
            members.len(),
            2,
            "only runtime workspaces should be emitted: {payload:#}"
        );
        assert!(
            members.iter().all(|member| member["path"] != "."),
            "workspace aggregator root must not receive a runtime setup recipe: {payload:#}"
        );
        assert_eq!(members[0]["path"], "apps/api");
        assert_eq!(members[0]["snippets"][0]["path"], "apps/api/src/index.ts");
        assert_eq!(members[1]["path"], "apps/web");
        assert_eq!(members[1]["framework_detected"], "vite");
    }

    #[test]
    fn setup_json_skips_vite_build_only_workspace_packages() {
        let dir = tempdir().expect("tempdir should be created");
        std::fs::write(
            dir.path().join("package.json"),
            r#"{
  "name": "repo",
  "private": true,
  "packageManager": "pnpm@9.0.0",
  "workspaces": ["packages/*"],
  "scripts": { "dev": "turbo dev" },
  "devDependencies": { "vite": "^6.0.0" }
}"#,
        )
        .expect("root package.json should be written");

        let library_dir = dir.path().join("packages/ui");
        std::fs::create_dir_all(library_dir.join("src"))
            .expect("library src dir should be created");
        std::fs::write(
            library_dir.join("package.json"),
            r#"{"name":"@repo/ui","scripts":{"build":"vite build"},"devDependencies":{"vite":"^6.0.0"}}"#,
        )
        .expect("library package.json should be written");

        let payload = build_setup_json(dir.path(), false);

        assert_eq!(payload["framework_detected"], "unknown");
        assert_eq!(payload["runtime_targets"], serde_json::json!([]));
        assert_eq!(payload["files_to_edit"], serde_json::json!([]));
        assert_eq!(payload["snippets"], serde_json::json!([]));
        assert_eq!(payload["members"], serde_json::json!([]));
        assert!(
            payload["warnings"]
                .as_array()
                .is_some_and(|warnings| warnings.iter().any(|warning| warning
                    .as_str()
                    .is_some_and(|warning| warning.contains("No runtime workspace members")))),
            "empty runtime workspace detection should be explicit: {payload:#}"
        );
    }

    #[test]
    fn recipe_contents_uses_detected_package_manager_scripts() {
        let context = CoverageSetupContext {
            framework: FrameworkKind::SvelteKit,
            package_manager: Some(PackageManager::Pnpm),
            has_build_script: true,
            has_start_script: false,
            has_preview_script: true,
            node_entry_path: "src/server.ts".to_owned(),
        };

        let recipe = recipe_contents(&context);

        assert!(recipe.contains("`pnpm build`"));
        assert!(recipe.contains("`NODE_V8_COVERAGE=./coverage pnpm preview`"));
    }

    #[test]
    fn recipe_contents_mentions_upload_inventory_ci_step() {
        let context = CoverageSetupContext {
            framework: FrameworkKind::SvelteKit,
            package_manager: Some(PackageManager::Pnpm),
            has_build_script: true,
            has_start_script: false,
            has_preview_script: true,
            node_entry_path: "src/server.ts".to_owned(),
        };
        let recipe = recipe_contents(&context);
        // Without this line the trial user finishes setup, wires the beacon,
        // and has no idea the dashboard's Untracked filter needs a second
        // CI step. Regression test for BLOCK 2 from the public-readiness
        // panel (2026-04-22).
        assert!(
            recipe.contains("fallow coverage upload-inventory"),
            "recipe missing upload-inventory CI instruction:\n{recipe}"
        );
        assert!(recipe.contains("FALLOW_API_KEY"));
    }

    #[test]
    fn recipe_contents_mentions_upload_inventory_without_build_script() {
        let context = CoverageSetupContext {
            framework: FrameworkKind::PlainNode,
            package_manager: Some(PackageManager::Npm),
            has_build_script: false,
            has_start_script: false,
            has_preview_script: false,
            node_entry_path: "src/server.ts".to_owned(),
        };
        let recipe = recipe_contents(&context);
        assert!(recipe.contains("fallow coverage upload-inventory"));
    }

    #[test]
    fn setup_json_is_deterministic_and_does_not_write_files() {
        let dir = tempdir().expect("tempdir should be created");
        std::fs::write(
            dir.path().join("package.json"),
            r#"{"name":"demo","packageManager":"pnpm@9.0.0","dependencies":{"next":"^16.0.0"}}"#,
        )
        .expect("package.json should be written");

        let payload = build_setup_json(dir.path(), false);

        assert_eq!(payload["schema_version"], "1");
        assert_eq!(payload["framework_detected"], "nextjs");
        assert_eq!(payload["package_manager"], "pnpm");
        assert_eq!(payload["config_written"], serde_json::Value::Null);
        assert_eq!(
            payload["runtime_targets"],
            serde_json::json!(["node", "browser"])
        );
        assert_eq!(payload["commands"][0], "pnpm add @fallow-cli/beacon");
        assert_eq!(payload["commands"][1], "pnpm add -D @fallow-cli/fallow-cov");
        assert_eq!(payload["files_to_edit"][0]["path"], "instrumentation.ts");
        assert!(
            payload["snippets"][0]["content"]
                .as_str()
                .is_some_and(|content| content.contains("createNodeBeacon"))
        );
        assert!(
            !dir.path().join("docs/collect-coverage.md").exists(),
            "JSON setup must not write the human recipe"
        );
    }

    #[test]
    fn setup_json_explain_includes_meta_without_bumping_schema_version() {
        let dir = tempdir().expect("tempdir should be created");
        std::fs::write(
            dir.path().join("package.json"),
            r#"{"name":"demo","packageManager":"bun@1.2.0","dependencies":{"elysia":"^1.0.0"}}"#,
        )
        .expect("package.json should be written");

        let payload = build_setup_json(dir.path(), true);

        assert_eq!(payload["schema_version"], "1");
        assert_eq!(
            payload["_meta"]["docs_url"],
            "https://docs.fallow.tools/cli/coverage#agent-readable-json"
        );
        assert!(
            payload["_meta"]["field_definitions"]
                .as_object()
                .is_some_and(|fields| fields.contains_key("members[]"))
        );
        assert!(
            payload["_meta"]["enums"]
                .as_object()
                .is_some_and(|enums| enums.contains_key("runtime_targets"))
        );
    }

    #[test]
    fn detect_coverage_artifact_finds_nyc_output_istanbul_file() {
        let dir = tempdir().expect("tempdir should be created");
        let nyc_dir = dir.path().join(".nyc_output");
        std::fs::create_dir_all(&nyc_dir).expect("nyc dir should be created");
        let coverage_file = nyc_dir.join("coverage-final.json");
        std::fs::write(&coverage_file, "{}").expect("coverage file should be written");

        assert_eq!(detect_coverage_artifact(dir.path()), Some(coverage_file));
    }
}
