use crate::{opt::process_file_to, BuildPhaseProfile};
use crate::{
    serve::WebServer, verbosity_or_default, BuildArtifacts, BuildRequest, BuildStage,
    BuilderUpdate, BundleFormat, ProgressRx, ProgressTx, Result, StructuredOutput,
};
use anyhow::{bail, Context, Error};
use futures_util::{future::OptionFuture, pin_mut, FutureExt};
use itertools::Itertools;
use std::{
    collections::HashSet,
    env,
    time::{Duration, SystemTime},
};
use std::{
    net::SocketAddr,
    path::{Path, PathBuf},
    process::Stdio,
};
use subsecond_types::JumpTable;
use target_lexicon::Architecture;
use tokio::{
    io::{AsyncBufReadExt, BufReader, Lines},
    process::{Child, ChildStderr, ChildStdout, Command},
    task::JoinHandle,
};
use tokio_stream::wrappers::UnboundedReceiverStream;

use super::{BuildContext, BuildId, BuildMode, HotpatchModuleCache};

/// The component of the serve engine that watches ongoing builds and manages their state, open handle,
/// and progress.
///
/// Previously, the builder allowed multiple apps to be built simultaneously, but this newer design
/// simplifies the code and allows only one app and its server to be built at a time.
///
/// Here, we track the number of crates being compiled, assets copied, the times of these events, and
/// other metadata that gives us useful indicators for the UI.
///
/// A handle to a running app.
///
/// The actual child processes might not be present (web) or running (died/killed).
///
/// The purpose of this struct is to accumulate state about the running app and its server, like
/// any runtime information needed to hotreload the app or send it messages.
///
/// We might want to bring in websockets here too, so we know the exact channels the app is using to
/// communicate with the devserver. Currently that's a broadcast-type system, so this struct isn't super
/// duper useful.
///
/// todo: restructure this such that "open" is a running task instead of blocking the main thread
pub(crate) struct AppBuilder {
    pub tx: ProgressTx,
    pub rx: ProgressRx,

    // The original request with access to its build directory
    pub build: BuildRequest,

    // Ongoing build task, if any
    pub build_task: JoinHandle<Result<BuildArtifacts>>,

    // If a build has already finished, we'll have its artifacts (rustc, link args, etc) to work with
    pub artifacts: Option<BuildArtifacts>,

    /// The aslr offset of this running app
    pub aslr_reference: Option<u64>,

    /// The list of patches applied to the app, used to know which ones to reapply and/or iterate from.
    pub patches: Vec<JumpTable>,
    pub patch_cache: Option<HotpatchModuleCache>,

    /// The virtual directory that assets will be served from
    /// Used mostly for apk/ipa builds since they live in simulator
    pub runtime_asset_dir: Option<PathBuf>,

    // These might be None if the app died or the user did not specify a server
    pub child: Option<Child>,

    // stdio for the app so we can read its stdout/stderr
    // we don't map stdin today (todo) but most apps don't need it
    pub stdout: Option<Lines<BufReader<ChildStdout>>>,
    pub stderr: Option<Lines<BufReader<ChildStderr>>>,

    // Android logcat stream (treated as stderr for error/warn levels)
    pub adb_logcat_stdout: Option<UnboundedReceiverStream<String>>,

    /// Handle to the task that's monitoring the child process
    pub spawn_handle: Option<JoinHandle<Result<()>>>,

    /// The executables but with some extra entropy in their name so we can run two instances of the
    /// same app without causing collisions on the filesystem.
    pub entropy_app_exe: Option<PathBuf>,
    pub builds_opened: usize,

    // Metadata about the build that needs to be managed by watching build updates
    // used to render the TUI
    pub stage: BuildStage,
    pub compiled_crates: usize,
    pub expected_crates: usize,
    pub bundling_progress: f64,
    pub compile_start: Option<SystemTime>,
    pub compile_end: Option<SystemTime>,
    pub bundle_start: Option<SystemTime>,
    pub bundle_end: Option<SystemTime>,

    /// The debugger for the app - must be enabled with the `d` key
    pub(crate) pid: Option<u32>,

    /// Cumulative set of workspace crates modified since the last fat build.
    /// Each patch includes objects from ALL crates in this set.
    pub modified_crates: HashSet<String>,

    /// The build profiling spans for us to generate a flamegraph from.
    pub profile_spans: Vec<BuildPhaseProfile>,
}

impl AppBuilder {
    /// Create a new `AppBuilder` and immediately start a build process.
    ///
    /// This method initializes the builder with the provided `BuildRequest` and spawns an asynchronous
    /// task (`build_task`) to handle the build process. The build process involves several stages:
    ///
    /// 1. **Tooling Verification**: Ensures that the necessary tools are available for the build.
    /// 2. **Build Directory Preparation**: Sets up the directory structure required for the build.
    /// 3. **Build Execution**: Executes the build process asynchronously.
    /// 4. **Bundling**: Packages the built artifacts into a final bundle.
    ///
    /// The `build_task` is a Tokio task that runs the build process in the background. It uses a
    /// `BuildContext` to manage the build state and communicate progress or errors via a message
    /// channel (`tx`).
    ///
    /// The builder is initialized with default values for various fields, such as the build stage,
    /// progress metrics, and optional runtime configurations.
    ///
    /// # Notes
    ///
    /// - The `build_task` is immediately spawned and will run independently of the caller.
    /// - The caller can use other methods on the `AppBuilder` to monitor the build progress or handle
    ///   updates (e.g., `wait`, `finish_build`).
    /// - The build process is designed to be cancellable and restartable using methods like `abort_all`
    ///   or `rebuild`.
    pub(crate) fn new(request: &BuildRequest) -> Result<Self> {
        let (tx, rx) = futures_channel::mpsc::unbounded();

        Ok(Self {
            build: request.clone(),
            stage: BuildStage::Initializing,
            build_task: tokio::task::spawn(std::future::pending()),
            tx,
            rx,
            patches: vec![],
            compiled_crates: 0,
            expected_crates: 1,
            bundling_progress: 0.0,
            builds_opened: 0,
            compile_start: Some(SystemTime::now()),
            aslr_reference: None,
            compile_end: None,
            bundle_start: None,
            bundle_end: None,
            runtime_asset_dir: None,
            child: None,
            stderr: None,
            stdout: None,
            adb_logcat_stdout: None,
            spawn_handle: None,
            entropy_app_exe: None,
            artifacts: None,
            patch_cache: None,
            pid: None,
            modified_crates: HashSet::new(),
            profile_spans: Vec::new(),
        })
    }

    /// Create a new `AppBuilder` and immediately start a build process.
    pub fn started(request: &BuildRequest, mode: BuildMode, build_id: BuildId) -> Result<Self> {
        let mut builder = Self::new(request)?;
        builder.start(mode, build_id);
        Ok(builder)
    }

    pub(crate) fn start(&mut self, mode: BuildMode, build_id: BuildId) {
        let request = self.build.clone();
        let tx = self.tx.clone();
        self.build_task = tokio::spawn(async move {
            let ctx = BuildContext::new(tx, mode, build_id);
            request.verify_tooling(&ctx).await?;
            request.prebuild(&ctx).await?;
            request.build(ctx).await
        });
    }

    /// Wait for any new updates to the builder - either it completed or gave us a message etc
    pub(crate) async fn wait(&mut self) -> BuilderUpdate {
        use futures_util::StreamExt;
        use BuilderUpdate::*;

        // Wait for the build to finish or for it to emit a status message
        let update = tokio::select! {
            Some(progress) = self.rx.next() => progress,
            bundle = (&mut self.build_task) => {
                // Replace the build with an infinitely pending task so we can select it again without worrying about deadlocks/spins
                self.build_task = tokio::task::spawn(std::future::pending());
                match bundle {
                    Ok(Ok(bundle)) => BuilderUpdate::BuildReady { bundle },
                    Ok(Err(err)) => BuilderUpdate::BuildFailed { err },
                    Err(err) => BuilderUpdate::BuildFailed { err: anyhow::anyhow!("Build panicked! {err:#?}") },
                }
            },
            Some(Ok(Some(msg))) = OptionFuture::from(self.stdout.as_mut().map(|f| f.next_line())) => {
                StdoutReceived {  msg }
            },
            Some(Ok(Some(msg))) = OptionFuture::from(self.stderr.as_mut().map(|f| f.next_line())) => {
                StderrReceived {  msg }
            },
            Some(msg) = OptionFuture::from(self.spawn_handle.as_mut()) => {
                // Prevent re-polling the spawn future, similar to above
                self.spawn_handle = None;
                match msg {
                    Ok(Ok(_)) => StdoutReceived { msg: "Finished launching app".to_string() },
                    Ok(Err(err)) => StderrReceived { msg: err.to_string() },
                    Err(err) => StderrReceived { msg: err.to_string() }
                }
            },
            Some(Some(msg)) = OptionFuture::from(self.adb_logcat_stdout.as_mut().map(|s| s.next())) => {
                // Send as stderr for errors/warnings, stdout for info/debug
                // Parse the priority level from a logcat line
                //
                // Logcat brief format: "I/TAG(12345): message"
                // Returns the priority char (V, D, I, W, E, F)
                if matches!(msg.chars().next().unwrap_or('I'), 'E' | 'W' | 'F') {
                    StderrReceived { msg }
                } else {
                    StdoutReceived { msg }
                }
            },
            Some(status) = OptionFuture::from(self.child.as_mut().map(|f| f.wait())) => {
                match status {
                    Ok(status) => {
                        self.child = None;
                        ProcessExited { status }
                    },
                    Err(err) => {
                        let () = futures_util::future::pending().await;
                        ProcessWaitFailed { err }
                    }
                }
            }
        };

        // Update the internal stage of the build so the UI can render it
        // *VERY IMPORTANT* - DO NOT AWAIT HERE
        // doing so will cause the changes to be lost since this wait call is called under a cancellable task
        // todo - move this handling to a separate function that won't be cancelled
        match &update {
            BuilderUpdate::Progress { .. } if self.is_finished() => {
                // Prevent updates from flowing in after the build has already finished
            }
            BuilderUpdate::Progress { stage } => {
                self.stage = stage.clone();

                match stage {
                    BuildStage::Initializing => {
                        self.profile_spans.clear();
                        self.compiled_crates = 0;
                        self.bundling_progress = 0.0;
                    }
                    BuildStage::Starting { crate_count, .. } => {
                        self.expected_crates = *crate_count.max(&1);
                    }
                    BuildStage::InstallingTooling => {}
                    BuildStage::Compiling { current, total, .. } => {
                        self.compiled_crates = *current;
                        self.expected_crates = *total.max(&1);

                        if self.compile_start.is_none() {
                            self.compile_start = Some(SystemTime::now());
                        }
                    }
                    BuildStage::Bundling => {
                        self.bundling_progress = 0.0;
                        self.bundle_start = Some(SystemTime::now());

                        if self.compile_end.is_none() {
                            self.compiled_crates = self.expected_crates;
                            self.compile_end = Some(SystemTime::now());
                        }
                    }
                    BuildStage::OptimizingWasm => {}
                    BuildStage::CopyingAssets { current, total, .. } => {
                        self.bundling_progress = *current as f64 / *total as f64;
                    }
                    BuildStage::Success => {
                        self.compiled_crates = self.expected_crates;
                        self.bundling_progress = 1.0;
                    }
                    BuildStage::Failed => {
                        self.compiled_crates = self.expected_crates;
                        self.bundling_progress = 1.0;
                    }
                    BuildStage::Aborted => {}
                    BuildStage::Restarting => {
                        self.compiled_crates = 0;
                        self.expected_crates = 1;
                        self.bundling_progress = 0.0;
                    }
                    BuildStage::RunningBindgen => {}
                    _ => {}
                }
            }
            BuilderUpdate::BuildReady { ref bundle } => {
                // Log the build completion as a telemetry event + provide analytics on build phases
                self.log_flamegraph_and_telemetry(bundle);

                // And then update the build state
                self.compiled_crates = self.expected_crates;
                self.bundling_progress = 1.0;
                self.stage = BuildStage::Success;

                self.bundle_end = Some(SystemTime::now());
                if self.compile_end.is_none() {
                    self.compiled_crates = self.expected_crates;
                    self.compile_end = Some(SystemTime::now());
                }
            }
            BuilderUpdate::BuildFailed { .. } => {
                tracing::debug!("Setting builder to failed state");
                self.stage = BuildStage::Failed;
            }
            BuilderUpdate::ProfilePhase { profile } => {
                // Collapse consecutive entries with the same label so e.g. per-crate "Compiling"
                // calls show up as a single span instead of hundreds.
                if self.profile_spans.last().map(|p| p.label) != Some(profile.label) {
                    self.profile_spans.push(profile.clone());
                }
            }
            BuilderUpdate::CompilerMessage { .. } => {}
            StdoutReceived { .. } => {}
            StderrReceived { .. } => {}
            ProcessExited { .. } => {}
            ProcessWaitFailed { .. } => {}
        }

        update
    }

    /// Kick off a thin (hotpatch) rebuild in response to a file change, replacing any in-flight
    /// build with one that produces a relinkable patch dylib (later applied by [`AppBuilder::hotpatch`]).
    ///
    /// Bails early if there's no prior fat build to link against, or if we're missing the ASLR
    /// reference on a non-wasm target (which means no client is connected yet to report it).
    ///
    /// Updates the cumulative `modified_crates` set: every patch is self-contained and contains
    /// the latest version of *every* crate modified since the fat build, not just the one that
    /// changed this iteration. We BFS `workspace_dependents_of` from `changed_crates` to catch
    /// the cascade — e.g. editing a leaf crate forces parent crates' generic instantiations to
    /// change too. (`compile_workspace_deps` does its own walk to decide what to *compile* now;
    /// this set tracks what to *link* into the patch.)
    ///
    /// Then aborts any in-flight build and spawns a fresh task with [`BuildMode::Thin`], handing
    /// it the fat build's `workspace_rustc_args`/`artifact_paths` (so rustc gets re-invoked with
    /// identical flags), the `HotpatchModuleCache` (symbol map for `create_jump_table`), and the
    /// current `object_cache` (per-crate `.rcgu.o` files used for assembly diffing).
    pub(crate) fn patch_rebuild(
        &mut self,
        changed_files: Vec<PathBuf>,
        changed_crates: Vec<String>,
        build_id: BuildId,
    ) {
        // We need the rustc args from the original build to pass to the new build
        let Some(artifacts) = self.artifacts.as_ref().cloned() else {
            tracing::warn!(
                "Ignoring patch rebuild for {build_id:?} since there is no existing build."
            );
            return;
        };

        // On web, our patches are fully relocatable, so we don't need to worry about ASLR, but
        // for all other platforms, we need to use the ASLR reference to know where to insert the patch.
        let aslr_reference = match self.aslr_reference {
            Some(val) => val,
            None if matches!(
                self.build.triple.architecture,
                Architecture::Wasm32 | Architecture::Wasm64
            ) =>
            {
                0
            }
            None => {
                tracing::warn!(
                    "Ignoring hotpatch since there is no ASLR reference. Is the client connected?"
                );
                return;
            }
        };

        let cache = artifacts
            .patch_cache
            .clone()
            .context("Failed to get patch cache")
            .unwrap();

        // Pre-compute the cumulative modified_crates set. Every patch includes objects from
        // ALL crates modified since the fat build. We compute the full cascade closure here
        // (while we have &mut self) so it doesn't need to be round-tripped through BuildArtifacts.
        //
        // Note: compile_workspace_deps() independently computes which crates to compile for THIS
        // patch (starting from changed_crates + cascade). That serves a different purpose — it only
        // compiles what changed now, not everything ever modified. Both use workspace_dependents_of
        // for the BFS, so they stay in sync automatically.
        let tip_crate_name = self.build.main_target.replace('-', "_");
        self.modified_crates.insert(tip_crate_name.clone());

        // Add changed crates and their transitive workspace dependents (cascade).
        let mut to_visit: Vec<String> = changed_crates.clone();
        let mut visited = HashSet::new();
        while let Some(c) = to_visit.pop() {
            if !visited.insert(c.clone()) {
                continue;
            }
            self.modified_crates.insert(c.clone());
            for dep in self.build.workspace_dependents_of(&c) {
                if dep != tip_crate_name && !visited.contains(&dep) {
                    to_visit.push(dep);
                }
            }
        }

        tracing::debug!(
            "Patch rebuild: changed_crates={:?}, modified_crates={:?}",
            changed_crates,
            self.modified_crates,
        );

        // Abort all the ongoing builds, cleaning up any loose artifacts and waiting to cleanly exit
        self.abort_all(BuildStage::Restarting);
        self.compile_start = Some(SystemTime::now());
        self.profile_spans.clear();
        self.build_task = tokio::spawn({
            let request = self.build.clone();
            let ctx = BuildContext::new(
                self.tx.clone(),
                BuildMode::Thin {
                    changed_files,
                    modified_crates: self.modified_crates.clone(),
                    workspace_rustc_args: artifacts.workspace_rustc,
                    aslr_reference,
                    cache,
                },
                build_id,
            );
            async move { request.build(ctx).await }
        });
    }

    /// Restart this builder with new build arguments.
    pub(crate) fn start_rebuild(&mut self, mode: BuildMode, build_id: BuildId) {
        // Abort all the ongoing builds, cleaning up any loose artifacts and waiting to cleanly exit
        // And then start a new build, resetting our progress/stage to the beginning and replacing the old tokio task
        self.abort_all(BuildStage::Restarting);
        self.artifacts.take();
        self.patch_cache.take();

        // A full rebuild resets all accumulated hotpatch state — the fat binary is a clean baseline.
        self.modified_crates.clear();
        self.profile_spans.clear();
        self.build_task = tokio::spawn({
            let request = self.build.clone();
            let tx = self.tx.clone();
            async move {
                let ctx = BuildContext::new(tx, mode, build_id);
                request.build(ctx).await
            }
        });
    }

    /// Shutdown the current build process
    ///
    /// todo: might want to use a cancellation token here to allow cleaner shutdowns
    pub(crate) fn abort_all(&mut self, stage: BuildStage) {
        self.stage = stage;
        self.compiled_crates = 0;
        self.expected_crates = 1;
        self.bundling_progress = 0.0;
        self.compile_start = None;
        self.bundle_start = None;
        self.bundle_end = None;
        self.compile_end = None;
        self.build_task.abort();
    }

    /// Wait for the build to finish, returning the final bundle
    /// Should only be used by code that's not interested in the intermediate updates and only cares about the final bundle
    ///
    /// todo(jon): maybe we want to do some logging here? The build/bundle/run screens could be made to
    /// use the TUI output for prettier outputs.
    pub(crate) async fn finish_build(&mut self) -> Result<BuildArtifacts> {
        loop {
            match self.wait().await {
                BuilderUpdate::Progress { stage } => {
                    match &stage {
                        BuildStage::Compiling {
                            current,
                            total,
                            krate,
                            fresh,
                            ..
                        } => {
                            if !fresh {
                                tracing::info!("Compiled [{current:>3}/{total}]: {krate}");
                            }
                        }
                        BuildStage::RunningBindgen => tracing::info!("Running wasm-bindgen..."),
                        BuildStage::CopyingAssets {
                            current,
                            total,
                            path,
                        } => {
                            tracing::info!(
                                "Copying asset ({}/{total}): {}",
                                current + 1,
                                path.display()
                            );
                        }
                        BuildStage::Bundling => tracing::info!("Bundling app..."),
                        BuildStage::CodeSigning => tracing::info!("Code signing app..."),
                        _ => {}
                    }

                    tracing::info!(json = %StructuredOutput::BuildUpdate { stage: stage.clone() });
                }
                BuilderUpdate::CompilerMessage { message } => {
                    tracing::info!(json = %StructuredOutput::RustcOutput { message: message.clone() }, %message);
                }
                BuilderUpdate::BuildReady { bundle } => {
                    tracing::debug!(json = %StructuredOutput::BuildFinished {
                        artifacts: bundle.clone().into_structured_output(),
                    });
                    return Ok(bundle);
                }
                BuilderUpdate::BuildFailed { err } => {
                    // Flush remaining compiler messages
                    while let Ok(msg) = self.rx.try_recv() {
                        if let BuilderUpdate::CompilerMessage { message } = msg {
                            tracing::info!(json = %StructuredOutput::RustcOutput { message: message.clone() }, %message);
                        }
                    }

                    return Err(err);
                }
                BuilderUpdate::ProfilePhase { .. } => {}
                BuilderUpdate::StdoutReceived { .. } => {}
                BuilderUpdate::StderrReceived { .. } => {}
                BuilderUpdate::ProcessExited { .. } => {}
                BuilderUpdate::ProcessWaitFailed { .. } => {}
            }
        }
    }

    /// Create a list of environment variables that the child process will use
    ///
    /// We try to emulate running under `cargo` as much as possible, carrying over vars like `CARGO_MANIFEST_DIR`.
    /// Previously, we didn't want to emulate this behavior, but now we do in order to be a good
    /// citizen of the Rust ecosystem and allow users to use `cargo` features like `CARGO_MANIFEST_DIR`.
    ///
    /// Note that Dioxus apps *should not* rely on this vars being set, but libraries like Bevy do.
    pub(crate) fn child_environment_variables(
        &self,
        devserver_ip: Option<SocketAddr>,
        start_fullstack_on_address: Option<SocketAddr>,
        always_on_top: bool,
        build_id: BuildId,
    ) -> Vec<(String, String)> {
        let krate = &self.build;

        // Set the env vars that the clients will expect
        // These need to be stable within a release version (ie 0.6.0)
        let mut envs: Vec<(String, String)> = vec![
            (
                dioxus_cli_config::CLI_ENABLED_ENV.into(),
                "true".to_string(),
            ),
            (
                dioxus_cli_config::APP_TITLE_ENV.into(),
                krate.config.web.app.title.clone(),
            ),
            (
                dioxus_cli_config::SESSION_CACHE_DIR.into(),
                self.build.session_cache_dir().display().to_string(),
            ),
            (dioxus_cli_config::BUILD_ID.into(), build_id.0.to_string()),
            (
                dioxus_cli_config::ALWAYS_ON_TOP_ENV.into(),
                always_on_top.to_string(),
            ),
        ];

        if let Some(devserver_ip) = devserver_ip {
            envs.push((
                dioxus_cli_config::DEVSERVER_IP_ENV.into(),
                devserver_ip.ip().to_string(),
            ));
            envs.push((
                dioxus_cli_config::DEVSERVER_PORT_ENV.into(),
                devserver_ip.port().to_string(),
            ));
        }

        if verbosity_or_default().verbose {
            envs.push(("RUST_BACKTRACE".into(), "1".to_string()));
        }

        if let Some(base_path) = krate.trimmed_base_path() {
            envs.push((
                dioxus_cli_config::ASSET_ROOT_ENV.into(),
                base_path.to_string(),
            ));
        }

        if let Some(env_filter) = env::var_os("RUST_LOG").and_then(|e| e.into_string().ok()) {
            envs.push(("RUST_LOG".into(), env_filter));
        }

        // Launch the server if we were given an address to start it on, and the build includes a server. After we
        // start the server, consume its stdout/stderr.
        if let Some(addr) = start_fullstack_on_address {
            envs.push((
                dioxus_cli_config::SERVER_IP_ENV.into(),
                addr.ip().to_string(),
            ));
            envs.push((
                dioxus_cli_config::SERVER_PORT_ENV.into(),
                addr.port().to_string(),
            ));
        }

        // If there's any CARGO vars in the captured rustc args, push those too.
        // Any captured invocation is sufficient since the CARGO_ environment is shared.
        if let Some(args) = self
            .artifacts
            .as_ref()
            .and_then(|artifacts| artifacts.workspace_rustc.rustc_args.values().next())
        {
            for (key, value) in args.envs.iter().cloned() {
                if key.starts_with("CARGO_") {
                    envs.push((key, value));
                }
            }
        }

        envs
    }

    #[allow(clippy::too_many_arguments)]
    pub(crate) async fn open(
        &mut self,
        devserver_ip: SocketAddr,
        open_address: Option<SocketAddr>,
        start_fullstack_on_address: Option<SocketAddr>,
        open_browser: bool,
        always_on_top: bool,
        build_id: BuildId,
        args: &[String],
    ) -> Result<()> {
        let envs = self.child_environment_variables(
            Some(devserver_ip),
            start_fullstack_on_address,
            always_on_top,
            build_id,
        );

        // We try to use stdin/stdout to communicate with the app
        match self.build.bundle {
            // Unfortunately web won't let us get a proc handle to it (to read its stdout/stderr) so instead
            // use use the websocket to communicate with it. I wish we could merge the concepts here,
            // like say, opening the socket as a subprocess, but alas, it's simpler to do that somewhere else.
            BundleFormat::Web => {
                // Only the first build we open the web app, after that the user knows it's running
                if open_browser {
                    self.open_web(open_address.unwrap_or(devserver_ip));
                }
            }

            BundleFormat::Ios => {
                if let Some(device) = self.build.device_name.to_owned() {
                    self.open_ios_device(&device).await?
                } else {
                    self.open_ios_sim(envs).await?
                }
            }

            BundleFormat::Android => {
                self.open_android(false, devserver_ip, envs, self.build.device_name.clone())
                    .await?;
            }

            // These are all just basically running the main exe, but with slightly different resource dir paths
            BundleFormat::Server
            | BundleFormat::MacOS
            | BundleFormat::Windows
            | BundleFormat::Linux => self.open_with_main_exe(envs, args)?,
        };

        self.builds_opened += 1;

        Ok(())
    }

    /// Gracefully kill the process and all of its children
    ///
    /// Uses the `SIGTERM` signal on unix and `taskkill` on windows.
    /// This complex logic is necessary for things like window state preservation to work properly.
    ///
    /// Also wipes away the entropy executables if they exist.
    pub(crate) async fn soft_kill(&mut self) {
        use futures_util::FutureExt;

        // Kill any running executables on Windows
        let Some(mut process) = self.child.take() else {
            return;
        };

        let Some(pid) = process.id() else {
            _ = process.kill().await;
            return;
        };

        // on unix, we can send a signal to the process to shut down
        #[cfg(unix)]
        {
            _ = Command::new("kill")
                .args(["-s", "TERM", &pid.to_string()])
                .spawn();
        }

        // on windows, use the `taskkill` command
        #[cfg(windows)]
        {
            _ = Command::new("taskkill")
                .args(["/PID", &pid.to_string()])
                .spawn();
        }

        // join the wait with a 100ms timeout
        futures_util::select! {
            _ = process.wait().fuse() => {}
            _ = tokio::time::sleep(std::time::Duration::from_millis(1000)).fuse() => {}
        };

        // Wipe out the entropy executables if they exist
        if let Some(entropy_app_exe) = self.entropy_app_exe.take() {
            _ = std::fs::remove_file(entropy_app_exe);
        }

        // Abort the spawn handle monitoring task if it exists
        if let Some(spawn_handle) = self.spawn_handle.take() {
            spawn_handle.abort();
        }
    }

    /// Apply the artifacts produced by a thin (hotpatch) build to the currently running app and
    /// return a [`JumpTable`] that the runtime can use to swap function pointers in place.
    ///
    /// This is the CLI-side counterpart to `subsecond`'s runtime patch loader. A thin build
    /// produces a self-contained dylib (`patch_exe`) that links against the symbols already
    /// resident in the running process. To make that dylib actually take effect, this function
    /// has to do four things, in order:
    ///
    /// 1. **Reconcile new assets.** Walks `res.assets` and, for any `asset!()` reference that
    ///    wasn't in the previous build, processes the source file (via `process_file_to`, which
    ///    runs the asset through the appropriate optimizer — esbuild for JS/CSS, image
    ///    re-encoding, etc.) and copies it into the live `asset_dir`. New assets are also
    ///    inserted into `self.artifacts.assets` so subsequent patches see them as already-known.
    ///    On Android the asset is additionally `adb push`'d to `/data/local/tmp/dx/assets/...`
    ///    so the on-device app can read it.
    ///
    /// 2. **Extend the file watcher set.** New `include!()` / `include_str!()` / `include_bytes!()`
    ///    targets discovered in `res.depinfo.files` are appended to the existing artifacts'
    ///    depinfo so the file watcher will pick up future edits to them.
    ///
    /// 3. **Build the jump table.** Calls [`BuildRequest::create_jump_table`] with the new dylib
    ///    and the [`HotpatchModuleCache`] (which holds the symbol map from the original fat
    ///    build). The cache is what lets us resolve "function `foo` in the new dylib" to "the
    ///    address of `foo` in the running process" so the runtime can patch the call sites. On
    ///    Android the dylib itself is also pushed to the device and `jump_table.lib` is
    ///    rewritten to point at the on-device path, since the runtime will `dlopen` it from
    ///    there rather than from the host filesystem.
    ///
    /// 4. **Commit local state.** The jump table is appended to `self.patches` (the cumulative
    ///    history of patches applied to this run of the app — used so that a fresh client
    ///    connecting mid-session can be brought up to date by replaying every patch in order),
    ///    and `self.object_cache` is overwritten with `res.object_cache` so the next thin build
    ///    diffs against the objects this patch produced rather than the previous tip.
    ///
    /// The returned [`JumpTable`] is what gets shipped to the runtime over the devserver
    /// websocket; the runtime then `dlopen`s `jump_table.lib` and rewrites the trampolines
    /// described by the table.
    ///
    /// # Errors
    ///
    /// Returns an error if there are no prior `artifacts` to patch against (i.e. no fat build
    /// has completed yet), if `create_jump_table` fails (typically a symbol-resolution failure
    /// indicating the patch references something that doesn't exist in the base binary), or if
    /// the Android `adb push` of the dylib fails. Failures while copying individual assets are
    /// logged and skipped rather than aborting the patch.
    ///
    /// # Panics
    ///
    /// Panics if `res.mode` is [`BuildMode::Thin`] but `changed_files` is empty — every thin
    /// build is triggered by at least one file change, so this should be unreachable in practice.
    pub(crate) async fn hotpatch(
        &mut self,
        res: &BuildArtifacts,
        cache: &HotpatchModuleCache,
    ) -> Result<JumpTable> {
        let original = self.build.main_exe();
        let new = self.build.patch_exe(res.time_start);
        let asset_dir = self.build.bundle_asset_dir();

        // Hotpatch asset!() calls
        for bundled in res.assets.unique_assets() {
            let original_artifacts = self
                .artifacts
                .as_mut()
                .context("No artifacts to hotpatch")?;

            if original_artifacts.assets.contains(bundled) {
                continue;
            }

            // If this is a new asset, insert it into the artifacts so we can track it when hot reloading
            original_artifacts.assets.insert_asset(*bundled);

            let from = dunce::canonicalize(PathBuf::from(bundled.absolute_source_path()))?;

            let to = asset_dir.join(bundled.bundled_path());

            tracing::debug!("Copying asset from patch: {}", from.display());
            let esbuild = crate::esbuild::Esbuild::path_if_installed();
            if let Err(e) = process_file_to(bundled.options(), &from, &to, esbuild.as_deref()) {
                tracing::error!("Failed to copy asset: {e}");
                continue;
            }

            // If the emulator is android, we need to copy the asset to the device with `adb push asset /data/local/tmp/dx/assets/filename.ext`
            if self.build.bundle == BundleFormat::Android {
                let bundled_name = PathBuf::from(bundled.bundled_path());
                _ = self.copy_file_to_android_tmp(&from, &bundled_name).await;
            }
        }

        // Make sure to add `include!()` calls to the watcher so we can watch changes as they evolve
        for file in res.depinfo.files.iter() {
            let original_artifacts = self
                .artifacts
                .as_mut()
                .context("No artifacts to hotpatch")?;

            if !original_artifacts.depinfo.files.contains(file) {
                original_artifacts.depinfo.files.push(file.clone());
            }
        }

        tracing::debug!("Patching {} -> {}", original.display(), new.display());

        let mut jump_table = self.build.create_jump_table(&new, cache)?;

        // If it's android, we need to copy the assets to the device and then change the location of the patch
        if self.build.bundle == BundleFormat::Android {
            jump_table.lib = self
                .copy_file_to_android_tmp(&new, &(PathBuf::from(new.file_name().unwrap())))
                .await?;
        }

        let changed_files = match &res.mode {
            BuildMode::Thin { changed_files, .. } => changed_files.clone(),
            _ => vec![],
        };

        use crate::styles::{GLOW_STYLE, NOTE_STYLE};

        let changed_file = changed_files.first().unwrap();
        tracing::info!(
            "Hot-patching: {NOTE_STYLE}{}{NOTE_STYLE:#} took {GLOW_STYLE}{:?}ms{GLOW_STYLE:#}",
            changed_file
                .strip_prefix(self.build.workspace_dir())
                .unwrap_or(changed_file)
                .display(),
            SystemTime::now()
                .duration_since(res.time_start)
                .unwrap()
                .as_millis()
        );

        // Commit this patch
        self.patches.push(jump_table.clone());

        Ok(jump_table)
    }

    /// Hotreload an asset in the running app.
    ///
    /// This will modify the build dir in place! Be careful! We generally assume you want all bundles
    /// to reflect the latest changes, so we will modify the bundle.
    ///
    /// However, not all platforms work like this, so we might also need to update a separate asset
    /// dir that the system simulator might be providing. We know this is the case for ios simulators
    /// and haven't yet checked for android.
    ///
    /// This will return the bundled name of the assets such that we can send it to the clients letting
    /// them know what to reload. It's not super important that this is robust since most clients will
    /// kick all stylsheets without necessarily checking the name.
    pub(crate) async fn hotreload_bundled_assets(
        &self,
        changed_file: &PathBuf,
    ) -> Option<Vec<PathBuf>> {
        let artifacts = self.artifacts.as_ref()?;

        // Use the build dir if there's no runtime asset dir as the override. For the case of ios apps,
        // we won't actually be using the build dir.
        let asset_dir = match self.runtime_asset_dir.as_ref() {
            Some(dir) => dir.to_path_buf().join("assets/"),
            None => self.build.bundle_asset_dir(),
        };

        // Canonicalize the path as Windows may use long-form paths "\\\\?\\C:\\".
        let changed_file = dunce::canonicalize(changed_file)
            .inspect_err(|e| tracing::debug!("Failed to canonicalize hotreloaded asset: {e}"))
            .ok()?;

        // The asset might've been renamed thanks to the manifest, let's attempt to reload that too
        let resources = artifacts.assets.get_assets_for_source(&changed_file)?;
        let mut bundled_names = Vec::new();
        for resource in resources {
            let output_path = asset_dir.join(resource.bundled_path());

            tracing::debug!("Hotreloading asset {changed_file:?} in target {asset_dir:?}");

            // Remove the old asset if it exists
            _ = std::fs::remove_file(&output_path);

            // And then process the asset with the options into the **old** asset location. If we recompiled,
            // the asset would be in a new location because the contents and hash have changed. Since we are
            // hotreloading, we need to use the old asset location it was originally written to.
            let options = *resource.options();
            let esbuild = crate::esbuild::Esbuild::path_if_installed();
            let res = process_file_to(&options, &changed_file, &output_path, esbuild.as_deref());
            let bundled_name = PathBuf::from(resource.bundled_path());
            if let Err(e) = res {
                tracing::debug!("Failed to hotreload asset {e}");
            }

            // If the emulator is android, we need to copy the asset to the device with `adb push asset /data/local/tmp/dx/assets/filename.ext`
            if self.build.bundle == BundleFormat::Android {
                _ = self
                    .copy_file_to_android_tmp(&changed_file, &bundled_name)
                    .await;
            }
            bundled_names.push(bundled_name);
        }

        Some(bundled_names)
    }

    /// Copy this file to the tmp folder on the android device, returning the path to the copied file
    ///
    /// When we push patches (.so), the runtime will dlopen the file from the tmp folder by first copying
    /// it to shared memory. This is a workaround since not all android devices will be rooted and we
    /// can't drop the file into the `/data/data/com.org.app/lib/` directory.
    pub(crate) async fn copy_file_to_android_tmp(
        &self,
        changed_file: &Path,
        bundled_name: &Path,
    ) -> Result<PathBuf> {
        let target = dioxus_cli_config::android_session_cache_dir().join(bundled_name);
        tracing::debug!("Pushing asset to device: {target:?}");

        let res = Command::new(&self.build.workspace.android_tools()?.adb)
            .arg("push")
            .arg(changed_file)
            .arg(&target)
            .output()
            .await
            .context("Failed to push asset to device");

        if let Err(e) = res {
            tracing::debug!("Failed to push asset to device: {e}");
        }

        Ok(target)
    }

    /// Open the native app simply by running its main exe
    ///
    /// Eventually, for mac, we want to run the `.app` with `open` to fix issues with `dylib` paths,
    /// but for now, we just run the exe directly. Very few users should be caring about `dylib` search
    /// paths right now, but they will when we start to enable things like swift integration.
    ///
    /// Server/liveview/desktop are all basically the same, though
    fn open_with_main_exe(&mut self, envs: Vec<(String, String)>, args: &[String]) -> Result<()> {
        let main_exe = self.app_exe();

        tracing::debug!("Opening app with main exe: {main_exe:?}");

        let mut child = Command::new(main_exe)
            .args(args)
            .envs(envs)
            .stderr(Stdio::piped())
            .stdout(Stdio::piped())
            .kill_on_drop(true)
            .spawn()?;

        let stdout = BufReader::new(child.stdout.take().unwrap());
        let stderr = BufReader::new(child.stderr.take().unwrap());
        self.stdout = Some(stdout.lines());
        self.stderr = Some(stderr.lines());
        self.child = Some(child);

        Ok(())
    }

    /// Open the web app by opening the browser to the given address.
    /// Check if we need to use https or not, and if so, add the protocol.
    /// Go to the basepath if that's set too.
    fn open_web(&self, address: SocketAddr) {
        let base_path = self.build.base_path();
        let https = self.build.config.web.https.enabled.unwrap_or_default();
        let protocol = if https { "https" } else { "http" };
        let base_path = match base_path {
            Some(base_path) => format!("/{}", base_path.trim_matches('/')),
            None => "".to_owned(),
        };
        _ = open::that_detached(format!("{protocol}://{address}{base_path}"));
    }

    /// Use `xcrun` to install the app to the simulator
    /// With simulators, we're free to basically do anything, so we don't need to do any fancy codesigning
    /// or entitlements, or anything like that.
    ///
    /// However, if there's no simulator running, this *might* fail.
    ///
    /// TODO(jon): we should probably check if there's a simulator running before trying to install,
    /// and open the simulator if we have to.
    async fn open_ios_sim(&mut self, envs: Vec<(String, String)>) -> Result<()> {
        tracing::debug!("Installing app to simulator {:?}", self.build.root_dir());

        let res = Command::new("xcrun")
            .arg("simctl")
            .arg("install")
            .arg("booted")
            .arg(self.build.root_dir())
            .output()
            .await?;

        tracing::debug!("Installed app to simulator with exit code: {res:?}");

        // Remap the envs to the correct simctl env vars
        // iOS sim lets you pass env vars but they need to be in the format "SIMCTL_CHILD_XXX=XXX"
        let ios_envs = envs
            .iter()
            .map(|(k, v)| (format!("SIMCTL_CHILD_{k}"), v.clone()));

        let mut child = Command::new("xcrun")
            .arg("simctl")
            .arg("launch")
            .arg("--console")
            .arg("booted")
            .arg(self.build.bundle_identifier())
            .envs(ios_envs)
            .stderr(Stdio::piped())
            .stdout(Stdio::piped())
            .kill_on_drop(true)
            .spawn()?;

        let stdout = BufReader::new(child.stdout.take().unwrap());
        let stderr = BufReader::new(child.stderr.take().unwrap());
        self.stdout = Some(stdout.lines());
        self.stderr = Some(stderr.lines());
        self.child = Some(child);

        Ok(())
    }

    /// Upload the app to the device and launch it
    async fn open_ios_device(&mut self, device_query: &str) -> Result<()> {
        let device_query = device_query.to_string();
        let root_dir = self.build.root_dir().clone();
        let application_id = self.build.bundle_identifier();
        self.spawn_handle = Some(tokio::task::spawn(async move {
            // 1. Find an active device
            let device_uuid = Self::get_ios_device_uuid(&device_query).await?;

            tracing::info!("Uploading app to iOS device, this might take a while...");

            // 2. Install the app to the device
            Self::install_ios_app(&device_uuid, &root_dir).await?;

            // 3. Launch the app into the background, paused
            Self::launch_ios_app_paused(&device_uuid, &application_id).await?;

            Result::Ok(()) as Result<()>
        }));

        Ok(())
    }

    /// Parse the xcrun output to get the device based on its name and connected state.
    ///
    /// ```json, ignore
    /// "connectionProperties" : {
    ///   "authenticationType" : "manualPairing",
    ///   "isMobileDeviceOnly" : false,
    ///   "lastConnectionDate" : "2025-08-15T01:46:43.182Z",
    ///   "pairingState" : "paired",
    ///   "potentialHostnames" : [
    ///     "00008130-0002058401E8001C.coredevice.local",
    ///     "67054C13-C6C8-5AC2-B967-24C040AD3F17.coredevice.local"
    ///   ],
    ///   "transportType" : "localNetwork",
    ///   "tunnelState" : "disconnected",
    ///   "tunnelTransportProtocol" : "tcp"
    /// },
    /// "deviceProperties" : {
    ///   "bootedFromSnapshot" : true,
    ///   "bootedSnapshotName" : "com.apple.os.update-A771E2B3E8C155D1B1188896B3247851B64737ACDE91A5B6F6C1F03A541406AA",
    ///   "ddiServicesAvailable" : false,
    ///   "developerModeStatus" : "enabled",
    ///   "hasInternalOSBuild" : false,
    ///   "name" : "Jon’s iPhone (2)",
    ///   "osBuildUpdate" : "22G86",
    ///   "osVersionNumber" : "18.6",
    ///   "rootFileSystemIsWritable" : false
    /// }
    /// ```
    async fn get_ios_device_uuid(device_name_query: &str) -> Result<String> {
        use serde_json::Value;

        let tmpfile = tempfile::NamedTempFile::new()
            .context("Failed to create temporary file for device list")?;

        Command::new("xcrun")
            .args([
                "devicectl".to_string(),
                "list".to_string(),
                "devices".to_string(),
                "--json-output".to_string(),
                tmpfile.path().to_str().unwrap().to_string(),
            ])
            .output()
            .await?;

        let json: Value = serde_json::from_str(&std::fs::read_to_string(tmpfile.path())?)
            .context("Failed to parse xcrun output")?;

        let devices = json
            .get("result")
            .context("Failed to parse xcrun output")?
            .get("devices")
            .context("Failed to parse xcrun output")?
            .as_array()
            .context("Failed to get devices from xcrun output")?;

        // by default, we just pick the first available device and then look for better fits.
        let mut device_idx = 0;

        match device_name_query.is_empty() {
            // If the user provided a query, then we look through the device list looking for the right one.
            // This searches both UUIDs and names, making it possible to paste an ID or a name.
            false => {
                use nucleo::{chars, Config, Matcher, Utf32Str};
                let normalize = |c: char| chars::to_lower_case(chars::normalize(c));
                let mut matcher = Matcher::new(Config::DEFAULT);
                let mut best_score = 0;
                let needle = device_name_query.chars().map(normalize).collect::<String>();
                for (idx, device) in devices.iter().enumerate() {
                    let device_name = device
                        .get("deviceProperties")
                        .and_then(|f| f.get("name"))
                        .and_then(|n| n.as_str())
                        .unwrap_or_default();
                    let device_uuid = device
                        .get("identifier")
                        .and_then(|n| n.as_str())
                        .unwrap_or_default();
                    let haystack = format!("{device_name} {device_uuid}")
                        .chars()
                        .map(normalize)
                        .collect::<String>();
                    let name_score = matcher.fuzzy_match(
                        Utf32Str::Ascii(haystack.as_bytes()),
                        Utf32Str::Ascii(needle.as_bytes()),
                    );
                    if let Some(score) = name_score {
                        if score > best_score {
                            best_score = score;
                            device_idx = idx;
                        }
                    }
                }

                if best_score == 0 {
                    tracing::warn!(
                        "No device found matching query: {device_name_query}. Using first available device."
                    );
                }
            }

            // If the query is empty, then we just find the first connected/available device
            // This is somewhat based on the bundle format, since we don't want to accidentally upload
            // iOS apps to watches/tvs
            true => {
                for (idx, device) in devices.iter().enumerate() {
                    let is_paired = device
                        .get("connectionProperties")
                        .and_then(|g| g.get("pairingState"))
                        .map(|s| s.as_str() == Some("paired"))
                        .unwrap_or(false);

                    let is_ios_device = matches!(
                        device
                            .get("hardwareProperties")
                            .and_then(|h| h.get("deviceType"))
                            .and_then(|s| s.as_str()),
                        Some("iPhone") | Some("iPad") | Some("iPod")
                    );

                    let is_available = device
                        .get("connectionProperties")
                        .and_then(|c| c.get("tunnelState"))
                        .and_then(|s| s.as_str())
                        != Some("unavailable");

                    if is_paired && is_ios_device && is_available {
                        device_idx = idx;
                        break;
                    }
                }
            }
        }

        devices
            .get(device_idx)
            .context("No devices found")?
            .get("identifier")
            .and_then(|id| id.as_str())
            .map(|s| s.to_string())
            .context("Failed to extract device UUID")
    }

    async fn install_ios_app(device_uuid: &str, app_path: &Path) -> Result<()> {
        let tmpfile = tempfile::NamedTempFile::new()
            .context("Failed to create temporary file for device list")?;

        // xcrun devicectl device install app --device <uuid> --path <path> --json-output
        let output = Command::new("xcrun")
            .args([
                "devicectl",
                "device",
                "install",
                "app",
                "--device",
                device_uuid,
                &app_path.display().to_string(),
                "--json-output",
            ])
            .arg(tmpfile.path())
            .output()
            .await?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);

            if stderr.contains("DeviceLocked") || stderr.contains("device is locked") {
                bail!(
                    "Failed to install app: your device is locked.\n\
                     Unlock your iPhone/iPad and try again."
                );
            }

            if stderr.contains("cannot be installed on this device")
                || stderr.contains("0xe8008012")
            {
                bail!(
                    "Failed to install app: your device is not registered in the provisioning profile.\n\n\
                     Your device UDID needs to be added to your Apple Developer account and the \
                     provisioning profile regenerated.\n\n\
                     To fix this:\n  \
                     1. Accept the latest Program License Agreement at:\n     \
                        https://developer.apple.com/account\n  \
                     2. Register your device at:\n     \
                        https://developer.apple.com/account/resources/devices\n  \
                     3. Regenerate your provisioning profile to include the new device\n  \
                     4. Or open any project in Xcode, select your device, and build —\n     \
                        Xcode will update the profile automatically\n\n\
                     Raw error: {stderr}"
                );
            }

            if stderr.contains("provisioning profile")
                || stderr.contains("ApplicationVerificationFailed")
                || stderr.contains("code signature")
            {
                bail!(
                    "Failed to install app: code signing error.\n\
                     A valid provisioning profile was not found for this app.\n\n\
                     To fix this:\n  \
                     1. Accept the latest Program License Agreement at:\n     \
                        https://developer.apple.com/account\n  \
                     2. Open the project in Xcode, select your device, and build once —\n     \
                        Xcode will set up signing and provisioning automatically\n  \
                     3. Ensure your device is registered in your Apple Developer account\n\n\
                     Raw error: {stderr}"
                );
            }

            bail!("Failed to install app to device {device_uuid}: {stderr}");
        }

        Ok(())
    }

    async fn launch_ios_app_paused(device_uuid: &str, application_id: &str) -> Result<()> {
        let tmpfile = tempfile::NamedTempFile::new()
            .context("Failed to create temporary file for device list")?;
        let output = Command::new("xcrun")
            .args([
                "devicectl",
                "device",
                "process",
                "launch",
                "--no-activate",
                "--verbose",
                "--device",
                device_uuid,
                application_id,
                "--json-output",
            ])
            .arg(tmpfile.path())
            .output()
            .await?;

        if !output.status.success() {
            bail!("Failed to launch app: {output:?}");
        }

        let json: serde_json::Value =
            serde_json::from_str(&std::fs::read_to_string(tmpfile.path())?)
                .context("Failed to parse xcrun output")?;

        let status_pid = json["result"]["process"]["processIdentifier"]
            .as_u64()
            .context("Failed to extract process identifier")?;

        let output = Command::new("xcrun")
            .args([
                "devicectl",
                "device",
                "process",
                "resume",
                "--device",
                device_uuid,
                "--pid",
                &status_pid.to_string(),
            ])
            .output()
            .await?;

        if !output.status.success() {
            bail!("Failed to resume app: {output:?}");
        }

        Ok(())
    }

    /// Launch the Android simulator and deploy the application.
    ///
    /// This function handles the process of starting the Android simulator, installing the APK,
    /// forwarding the development server port, and launching the application on the simulator.
    ///
    /// The following `adb` commands are executed:
    ///
    /// 1. **Enable Root Access**:
    ///    - `adb root`: Enables root access on the Android simulator, allowing for advanced operations like pushing files to restricted directories.
    ///
    /// 2. **Port Forwarding**:
    ///    - `adb reverse tcp:<port> tcp:<port>`: Forwards the development server port from the host
    ///      machine to the Android simulator, enabling communication between the app and the dev server.
    ///
    /// 3. **APK Installation**:
    ///    - `adb install -r <apk_path>`: Installs the APK onto the Android simulator. The `-r` flag
    ///      ensures that any existing installation of the app is replaced.
    ///
    /// 4. **Environment Variables**:
    ///    - Writes environment variables to a `.env` file in the session cache directory.
    ///    - `adb push <local_env_file> <device_env_file>`: Pushes the `.env` file to the Android device
    ///      to configure runtime environment variables for the app.
    ///
    /// 5. **App Launch**:
    ///    - `adb shell am start -n <package_name>/<activity_name>`: Launches the app on the Android
    ///      simulator. The `<package_name>` and `<activity_name>` are derived from the app's configuration.
    ///
    /// # Notes
    ///
    /// - This function is asynchronous and spawns a background task to handle the simulator setup and app launch.
    /// - The Android tools (`adb`) must be available in the system's PATH for this function to work.
    /// - If the app fails to launch, errors are logged for debugging purposes.
    ///
    /// # Resources:
    /// - <https://developer.android.com/studio/run/emulator-commandline>
    async fn open_android(
        &mut self,
        root: bool,
        devserver_socket: SocketAddr,
        envs: Vec<(String, String)>,
        device_name_query: Option<String>,
    ) -> Result<()> {
        let apk_path = self.build.debug_apk_path();
        let session_cache = self.build.session_cache_dir();
        let application_id = self.build.bundle_identifier();
        let adb = self.build.workspace.android_tools()?.adb.clone();
        let (stdout_tx, stdout_rx) = tokio::sync::mpsc::unbounded_channel::<String>();

        // Start backgrounded since .open() is called while in the arm of the top-level match
        let task = tokio::task::spawn(async move {
            // call `adb root` so we can push patches to the device
            if root {
                if let Err(e) = Command::new(&adb).arg("root").output().await {
                    tracing::error!("Failed to run `adb root`: {e}");
                }
            }

            // Try to get the transport ID for the device in case there are multiple specified devices
            // All future commands should use this since its the most recent.
            let transport_id_args =
                Self::get_android_device_transport_id(&adb, device_name_query.as_deref()).await;

            // Wait for device to be ready
            let cmd = Command::new(&adb)
                .args(transport_id_args)
                .arg("wait-for-device")
                .arg("shell")
                .arg(r#"while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;"#)
                .output();
            let cmd_future = cmd.fuse();
            pin_mut!(cmd_future);
            tokio::select! {
                _ = &mut cmd_future => {}
                _ = tokio::time::sleep(Duration::from_millis(50)) => {
                    tracing::info!("Waiting for android emulator to be ready...");
                    _ = cmd_future.await;
                }
            }

            let port = devserver_socket.port();
            if let Err(e) = Command::new(&adb)
                .arg("reverse")
                .arg(format!("tcp:{port}"))
                .arg(format!("tcp:{port}"))
                .output()
                .await
            {
                tracing::error!("failed to forward port {port}: {e}");
            }

            // Install
            // adb install -r app-debug.apk
            let res = Command::new(&adb)
                .arg("install")
                .arg("-r")
                .arg(apk_path)
                .output()
                .await?;
            let std_err = String::from_utf8_lossy(&res.stderr);
            if !std_err.is_empty() {
                tracing::error!("Failed to install apk with `adb`: {std_err}");
            }

            // Clear the session cache dir on the device
            Command::new(&adb)
                .arg("shell")
                .arg("rm")
                .arg("-rf")
                .arg(dioxus_cli_config::android_session_cache_dir())
                .output()
                .await?;

            // Write the env vars to a .env file in our session cache
            let env_file = session_cache.join(".env");
            _ = std::fs::write(
                &env_file,
                envs.iter()
                    .map(|(key, value)| format!("{key}={value}"))
                    .collect::<Vec<_>>()
                    .join("\n"),
            );

            // Push the env file to the device
            Command::new(&adb)
                .arg("push")
                .arg(env_file)
                .arg(dioxus_cli_config::android_session_cache_dir().join(".env"))
                .output()
                .await?;

            // eventually, use the user's MainActivity, not our MainActivity
            // adb shell am start -n dev.dioxus.main/dev.dioxus.main.MainActivity
            let activity_name = format!("{application_id}/dev.dioxus.main.MainActivity");
            let res = Command::new(&adb)
                .arg("shell")
                .arg("am")
                .arg("start")
                .arg("-n")
                .arg(activity_name)
                .output()
                .await?;
            let std_err = String::from_utf8_lossy(res.stderr.trim_ascii());
            if !std_err.is_empty() {
                tracing::error!("Failed to start app with `adb`: {std_err}");
            }

            // Try to get the transport ID for the device
            let transport_id_args =
                Self::get_android_device_transport_id(&adb, device_name_query.as_deref()).await;

            // Get the app's PID with retries
            // Retry up to 10 times (10 seconds total) since app launch is asynchronous
            let mut pid: Option<String> = None;
            for attempt in 1..=10 {
                match Self::get_android_app_pid(&adb, &application_id, &transport_id_args).await {
                    Ok(p) => {
                        pid = Some(p);
                        break;
                    }
                    Err(_) if attempt < 10 => {
                        tracing::debug!(
                            "App PID not found yet, retrying in 1 second... (attempt {}/10)",
                            attempt
                        );
                        tokio::time::sleep(Duration::from_secs(1)).await;
                    }
                    Err(e) => {
                        return Err(e).context(
                            "Failed to get app PID after 10 attempts - app may not have started",
                        );
                    }
                }
            }

            let pid = pid.context("Failed to get app PID")?;

            // Spawn logcat with filtering
            // By default: show only RustStdoutStderr (app Rust logs) and fatal errors
            // With tracing enabled: show all logs from the app process
            // Note: We always capture at DEBUG level, then filter in Rust based on trace flag
            let mut child = Command::new(&adb)
                .args(&transport_id_args)
                .arg("logcat")
                .arg("-v")
                .arg("brief")
                .arg("--pid")
                .arg(&pid)
                .arg("*:D") // Capture all logs at DEBUG level (filtered in Rust)
                .stdout(Stdio::piped())
                .stderr(Stdio::null())
                .kill_on_drop(true)
                .spawn()?;

            let stdout = child.stdout.take().unwrap();
            let mut reader = BufReader::new(stdout).lines();

            while let Ok(Some(line)) = reader.next_line().await {
                _ = stdout_tx.send(line);
            }

            Ok::<(), Error>(())
        });

        self.spawn_handle = Some(task);
        self.adb_logcat_stdout = Some(UnboundedReceiverStream::new(stdout_rx));

        Ok(())
    }

    fn make_entropy_path(exe: &PathBuf) -> PathBuf {
        let id = uuid::Uuid::new_v4();
        let name = id.to_string();
        let some_entropy = name.split('-').next().unwrap();

        // Split up the exe into the file stem and extension
        let extension = exe.extension().unwrap_or_default();
        let file_stem = exe.file_stem().unwrap().to_str().unwrap();

        // Make a copy of the server exe with a new name
        let entropy_server_exe = exe
            .with_file_name(format!("{}-{}", file_stem, some_entropy))
            .with_extension(extension);

        std::fs::copy(exe, &entropy_server_exe).unwrap();

        entropy_server_exe
    }

    fn app_exe(&mut self) -> PathBuf {
        let mut main_exe = self.build.main_exe();

        // The requirement here is based on the platform, not necessarily our current architecture.
        let requires_entropy = match self.build.bundle {
            // When running "bundled", we don't need entropy
            BundleFormat::Web | BundleFormat::MacOS | BundleFormat::Ios | BundleFormat::Android => {
                false
            }

            // But on platforms that aren't running as "bundled", we do.
            BundleFormat::Windows | BundleFormat::Linux | BundleFormat::Server => true,
        };

        if requires_entropy || crate::devcfg::should_force_entropy() {
            // If we already have an entropy app exe, return it - this is useful for re-opening the same app
            if let Some(existing_app_exe) = self.entropy_app_exe.clone() {
                return existing_app_exe;
            }

            let entropy_app_exe = Self::make_entropy_path(&main_exe);
            self.entropy_app_exe = Some(entropy_app_exe.clone());
            main_exe = entropy_app_exe;
        }

        main_exe
    }

    /// Get the total duration of the build, if all stages have completed
    pub(crate) fn total_build_time(&self) -> Option<Duration> {
        Some(self.compile_duration()? + self.bundle_duration()?)
    }

    pub(crate) fn compile_duration(&self) -> Option<Duration> {
        self.compile_end
            .unwrap_or_else(SystemTime::now)
            .duration_since(self.compile_start?)
            .ok()
    }

    pub(crate) fn bundle_duration(&self) -> Option<Duration> {
        self.bundle_end
            .unwrap_or_else(SystemTime::now)
            .duration_since(self.bundle_start?)
            .ok()
    }

    /// Return a number between 0 and 1 representing the progress of the app build
    pub(crate) fn compile_progress(&self) -> f64 {
        self.compiled_crates as f64 / self.expected_crates as f64
    }

    pub(crate) fn bundle_progress(&self) -> f64 {
        self.bundling_progress
    }

    pub(crate) fn is_finished(&self) -> bool {
        match self.stage {
            BuildStage::Success => true,
            BuildStage::Failed => true,
            BuildStage::Aborted => true,
            BuildStage::Restarting => false,
            _ => false,
        }
    }

    /// Check if the queued build is blocking hotreloads
    pub(crate) fn can_receive_hotreloads(&self) -> bool {
        matches!(&self.stage, BuildStage::Success | BuildStage::Failed)
    }

    pub(crate) async fn open_debugger(&mut self, server: &WebServer) -> Result<()> {
        // Get the preferred editor from workspace settings, defaulting to VS Code
        use crate::settings::SupportedEditor;
        let preferred_editor = self
            .build
            .workspace
            .settings
            .preferred_editor
            .unwrap_or(SupportedEditor::Vscode);

        // Map the editor to its binary and URL scheme
        let (editor_binary, url_scheme) = match preferred_editor {
            SupportedEditor::Vscode => ("code", "vscode"),
            SupportedEditor::Cursor => ("cursor", "cursor"),
        };

        let url = match self.build.bundle {
            BundleFormat::MacOS
            | BundleFormat::Windows
            | BundleFormat::Linux
            | BundleFormat::Server => {
                let Some(Some(pid)) = self.child.as_mut().map(|f| f.id()) else {
                    tracing::warn!("No process to attach debugger to");
                    return Ok(());
                };

                format!(
                    "{url_scheme}://vadimcn.vscode-lldb/launch/config?{{'request':'attach','pid':{pid}}}"
                )
            }

            BundleFormat::Web => {
                // code --open-url "vscode://DioxusLabs.dioxus/debugger?uri=http://127.0.0.1:8080"
                // todo - debugger could open to the *current* page afaik we don't have a way to have that info
                let address = server.devserver_address();
                let base_path = self.build.base_path();
                let https = self.build.config.web.https.enabled.unwrap_or_default();
                let protocol = if https { "https" } else { "http" };
                let base_path = match base_path {
                    Some(base_path) => format!("/{}", base_path.trim_matches('/')),
                    None => "".to_owned(),
                };
                format!("{url_scheme}://DioxusLabs.dioxus/debugger?uri={protocol}://{address}{base_path}")
            }

            BundleFormat::Ios => {
                let Some(pid) = self.pid else {
                    tracing::warn!("No process to attach debugger to");
                    return Ok(());
                };

                format!(
                    "{url_scheme}://vadimcn.vscode-lldb/launch/config?{{'request':'attach','pid':{pid}}}"
                )
            }

            // https://stackoverflow.com/questions/53733781/how-do-i-use-lldb-to-debug-c-code-on-android-on-command-line/64997332#64997332
            // https://android.googlesource.com/platform/development/+/refs/heads/main/scripts/gdbclient.py
            // run lldbserver on the device and then connect
            //
            // # TODO: https://code.visualstudio.com/api/references/vscode-api#debug and
            // #       https://code.visualstudio.com/api/extension-guides/debugger-extension and
            // #       https://github.com/vadimcn/vscode-lldb/blob/6b775c439992b6615e92f4938ee4e211f1b060cf/extension/pickProcess.ts#L6
            //
            // res = {
            //     "name": "(lldbclient.py) Attach {} (port: {})".format(binary_name.split("/")[-1], port),
            //     "type": "lldb",
            //     "request": "custom",
            //     "relativePathBase": root,
            //     "sourceMap": { "/b/f/w" : root, '': root, '.': root },
            //     "initCommands": ['settings append target.exec-search-paths {}'.format(' '.join(solib_search_path))],
            //     "targetCreateCommands": ["target create {}".format(binary_name),
            //                              "target modules search-paths add / {}/".format(sysroot)],
            //     "processCreateCommands": ["gdb-remote {}".format(str(port))]
            // }
            //
            // https://github.com/vadimcn/codelldb/issues/213
            //
            // lots of pain to figure this out:
            //
            // (lldb) image add target/dx/tw6/debug/android/app/app/src/main/jniLibs/arm64-v8a/libdioxusmain.so
            // (lldb) settings append target.exec-search-paths target/dx/tw6/debug/android/app/app/src/main/jniLibs/arm64-v8a/libdioxusmain.so
            // (lldb) process handle SIGSEGV --pass true --stop false --notify true (otherwise the java threads cause crash)
            //
            BundleFormat::Android => {
                // adb push ./sdk/ndk/29.0.13113456/toolchains/llvm/prebuilt/darwin-x86_64/lib/clang/20/lib/linux/aarch64/lldb-server /tmp
                // adb shell "/tmp/lldb-server --server --listen ..."
                // "vscode://vadimcn.vscode-lldb/launch/config?{{'request':'connect','port': {}}}",
                // format!(
                //     "vscode://vadimcn.vscode-lldb/launch/config?{{'request':'attach','pid':{pid}}}"
                // )
                let tools = &self.build.workspace.android_tools()?;

                // get the pid of the app
                let pid = Command::new(&tools.adb)
                    .arg("shell")
                    .arg("pidof")
                    .arg(self.build.bundle_identifier())
                    .output()
                    .await
                    .ok()
                    .and_then(|output| String::from_utf8(output.stdout).ok())
                    .and_then(|s| s.trim().parse::<u32>().ok())
                    .unwrap();

                // copy the lldb-server to the device
                let lldb_server = tools
                    .android_tools_dir()
                    .parent()
                    .unwrap()
                    .join("lib")
                    .join("clang")
                    .join("20")
                    .join("lib")
                    .join("linux")
                    .join("aarch64")
                    .join("lldb-server");

                tracing::info!("Copying lldb-server to device: {lldb_server:?}");

                _ = Command::new(&tools.adb)
                    .arg("push")
                    .arg(lldb_server)
                    .arg("/tmp/lldb-server")
                    .output()
                    .await;

                // Forward requests on 10086 to the device
                _ = Command::new(&tools.adb)
                    .arg("forward")
                    .arg("tcp:10086")
                    .arg("tcp:10086")
                    .output()
                    .await;

                // start the server - running it multiple times will make the subsequent ones fail (which is fine)
                _ = Command::new(&tools.adb)
                    .arg("shell")
                    .arg(r#"cd /tmp && ./lldb-server platform --server --listen '*:10086'"#)
                    .kill_on_drop(false)
                    .stdin(Stdio::null())
                    .stdout(Stdio::piped())
                    .stderr(Stdio::piped())
                    .spawn();

                let program_path = self.build.main_exe();
                format!(
                    r#"{url_scheme}://vadimcn.vscode-lldb/launch/config?{{
                        'name':'Attach to Android',
                        'type':'lldb',
                        'request':'attach',
                        'pid': '{pid}',
                        'processCreateCommands': [
                            'platform select remote-android',
                            'platform connect connect://localhost:10086',
                            'settings set target.inherit-env false',
                            'settings set target.inline-breakpoint-strategy always',
                            'settings set target.process.thread.step-avoid-regexp \"JavaBridge|JDWP|Binder|ReferenceQueueDaemon\"',
                            'process handle SIGSEGV --pass true --stop false --notify true"',
                            'settings append target.exec-search-paths {program_path}',
                            'attach --pid {pid}',
                            'continue'
                        ]
                    }}"#,
                    program_path = program_path.display(),
                )
                .lines()
                .map(|line| line.trim())
                .join("")
            }
        };

        tracing::info!("Opening debugger for [{}]: {url}", self.build.bundle);

        _ = tokio::process::Command::new(editor_binary)
            .arg("--open-url")
            .arg(url)
            .spawn();

        Ok(())
    }

    async fn get_android_device_transport_id(
        adb: &PathBuf,
        device_name_query: Option<&str>,
    ) -> Vec<String> {
        // If there are multiple devices, we pick the one matching the query
        let mut device_specifier_args = vec![];

        if let Some(device_name_query) = device_name_query {
            if let Ok(res) = Command::new(adb).arg("devices").arg("-l").output().await {
                let devices = String::from_utf8_lossy(&res.stdout);
                let mut best_score = 0;
                let mut device_identifier = "".to_string();
                use nucleo::{chars, Config, Matcher, Utf32Str};
                let mut matcher = Matcher::new(Config::DEFAULT);
                let normalize = |c: char| chars::to_lower_case(chars::normalize(c));
                let needle = device_name_query.chars().map(normalize).collect::<Vec<_>>();

                for line in devices.lines() {
                    let device_name = line.split_whitespace().next().unwrap_or("");
                    let Some(transport_id) = line
                        .split_whitespace()
                        .find(|s| s.starts_with("transport_id:"))
                        .map(|s| s.trim_start_matches("transport_id:"))
                    else {
                        continue;
                    };

                    let device_name = device_name.chars().map(normalize).collect::<Vec<_>>();
                    let score = matcher
                        .fuzzy_match(Utf32Str::Unicode(&device_name), Utf32Str::Unicode(&needle));
                    if let Some(score) = score {
                        if score > best_score {
                            best_score = score;
                            device_identifier = transport_id.to_string();
                        }
                    }
                }

                if best_score != 0 {
                    device_specifier_args.push("-t".to_string());
                    device_specifier_args.push(device_identifier.to_string());
                }
            }

            if device_specifier_args.is_empty() {
                tracing::warn!(
                    "No device found matching query: {device_name_query}. Using default transport ID."
                );
            }
        }

        device_specifier_args
    }

    /// Get the PID of the running Android app
    async fn get_android_app_pid(
        adb: &Path,
        application_id: &str,
        transport_id_args: &[String],
    ) -> Result<String> {
        let output = Command::new(adb)
            .args(transport_id_args)
            .arg("shell")
            .arg("pidof")
            .arg(application_id)
            .output()
            .await?;

        let pid = String::from_utf8(output.stdout)?.trim().to_string();

        if pid.is_empty() {
            anyhow::bail!("App process not found - may not have started yet");
        }

        Ok(pid)
    }

    /// Log the profile flamegraph for this build run, and then emit a telemetry event.
    ///
    /// This works by walking the profile_spans vec, calculating the deltas, and then rendering out
    /// the flamegraph via a debug!()
    ///
    /// The flamegraph looks something like this:
    ///
    /// ```txt
    /// 17:22:56 [dev] Flamegraph for fat - time taken: 11371ms
    ///        0ms   0.0% Verify Tooling       |█                                                                                               |
    ///        0ms   0.0% Installing Tooling   |█                                                                                               |
    ///      100ms   0.9% Prebuild             |██                                                                                              |
    ///      307ms   2.7% Workspace precompile | ███                                                                                            |
    ///     1400ms  12.3% Compiling            |   █████████████                                                                                |
    ///     1325ms  11.7% Fat Linking          |               ████████████                                                                     |
    ///      915ms   8.0% Extracting assets    |                          █████████                                                             |
    ///     1529ms  13.4% Writing executable   |                                  ██████████████                                                |
    ///     4685ms  41.2% Wasm Bindgen         |                                               █████████████████████████████████████████        |
    ///     1061ms   9.3% Creating Patch Cache |                                                                                       █████████|
    /// ```
    fn log_flamegraph_and_telemetry(&self, bundle: &BuildArtifacts) {
        let Some(start) = self.compile_start else {
            tracing::debug!("no compile start?");
            return;
        };

        // Record the build duration as a telemetry event. Capture `now` once and use
        // it as the end of the final phase too, so the flamegraph bars stay
        // internally consistent with `time_taken`.
        let now = SystemTime::now();
        let time_taken = now
            .duration_since(start)
            .unwrap_or_default()
            .as_millis()
            .max(1);

        tracing::debug!(
            telemetry = %serde_json::json!({
                "event": "build_and_bundle_complete",
                "time_taken": time_taken,
                "mode": match bundle.mode {
                    BuildMode::Base { .. } => "base",
                    BuildMode::Fat => "fat",
                    BuildMode::Thin { .. } => "thin",
                },
                "blah": 123,
                "triple": self.build.triple.to_string(),
                "format": self.build.bundle.to_string(),
                "num_dependencies": self.build.workspace.krates.len(),
            }),
            "Build completed in {time_taken}ms",
        );

        // Render the flamegraph out by walking the span history. This is pretty naive
        // and doesn't support nested span contexts (yet!) - all phases live on one row
        // and a phase ends where the next one begins.
        use std::fmt::Write as _;

        let total_ms = time_taken as usize;
        let timeline_width = 48usize;
        let max_label_width = self
            .profile_spans
            .iter()
            .map(|phase| phase.label.len())
            .max()
            .unwrap_or(0)
            .min(28);

        // The final phase runs right up until `now`. Don't use `compile_end` here:
        // it's set when the Bundling stage starts, so it predates the bundling phases
        // themselves and would zero out the last bar.
        let phase_end = now;

        let mut flamegraph = format!(
            "Flamegraph for {} - time taken: {}ms",
            match bundle.mode {
                BuildMode::Base { .. } => "base",
                BuildMode::Fat => "fat",
                BuildMode::Thin { .. } => "thin",
            },
            time_taken
        );

        let mut phase_iter = self.profile_spans.iter().peekable();
        while let Some(phase) = phase_iter.next() {
            let end_time = phase_iter.peek().map(|f| f.start).unwrap_or(phase_end);

            let offset_ms = phase
                .start
                .duration_since(start)
                .unwrap_or_default()
                .as_millis() as usize;
            let dur_ms = end_time
                .duration_since(phase.start)
                .unwrap_or_default()
                .as_millis() as usize;
            let pct = (dur_ms as f64) * 100.0 / (total_ms as f64);

            let bar_start = ((offset_ms * timeline_width) / total_ms).min(timeline_width - 1);
            let bar_end = (((offset_ms + dur_ms) * timeline_width).div_ceil(total_ms))
                .max(bar_start + 1)
                .min(timeline_width);

            let mut bar = vec![' '; timeline_width];
            for ch in &mut bar[bar_start..bar_end] {
                *ch = '█';
            }
            let bar: String = bar.into_iter().collect();

            // Labels are ASCII so byte slicing is safe; truncate so the bar column
            // stays aligned across rows.
            let label = if phase.label.len() > max_label_width {
                &phase.label[..max_label_width]
            } else {
                phase.label
            };

            let _ = write!(
                flamegraph,
                "\n  {:>6}ms {:>5.1}% {:<width$} |{}|",
                dur_ms,
                pct,
                label,
                bar,
                width = max_label_width
            );
        }

        tracing::debug!("{}", flamegraph);
    }

    /// Pre-render the static routes, performing static-site generation
    pub(crate) async fn pre_render_static_routes(
        &mut self,
        devserver_ip: Option<SocketAddr>,
        updates: Option<&futures_channel::mpsc::UnboundedSender<BuilderUpdate>>,
    ) -> anyhow::Result<()> {
        use super::{BuildId, BuilderUpdate};
        use anyhow::Context;
        use dioxus_cli_config::{server_ip, server_port};
        use dioxus_dx_wire_format::BuildStage;
        use futures_util::{stream::FuturesUnordered, StreamExt};
        use std::{
            net::{IpAddr, Ipv4Addr, SocketAddr},
            time::Duration,
        };
        use tokio::process::Command;

        if let Some(updates) = updates {
            updates
                .unbounded_send(BuilderUpdate::Progress {
                    stage: BuildStage::Prerendering,
                })
                .unwrap();
        }
        let server_exe = self.build.main_exe();

        // Use the address passed in through environment variables or default to localhost:9999. We need
        // to default to a value that is different than the CLI default address to avoid conflicts
        let ip = server_ip().unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
        let port = server_port().unwrap_or(9999);
        let fullstack_address = SocketAddr::new(ip, port);
        let address = fullstack_address.ip().to_string();
        let port = fullstack_address.port().to_string();

        // Borrow port and address so we can easily move them into multiple tasks below
        let address = &address;
        let port = &port;

        tracing::info!("Running SSG at http://{address}:{port} for {server_exe:?}");

        let vars = self.child_environment_variables(
            devserver_ip,
            Some(fullstack_address),
            false,
            BuildId::SECONDARY,
        );

        // Run the server executable
        let _child = Command::new(&server_exe)
            .envs(vars)
            .current_dir(server_exe.parent().unwrap())
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .kill_on_drop(true)
            .spawn()?;

        // Borrow reqwest_client so we only move the reference into the futures
        let reqwest_client = reqwest::Client::new();
        let reqwest_client = &reqwest_client;

        // Get the routes from the `/static_routes` endpoint
        let mut routes = None;

        // The server may take a few seconds to start up. Try fetching the route up to 5 times with a one second delay
        const RETRY_ATTEMPTS: usize = 5;
        for i in 0..=RETRY_ATTEMPTS {
            tracing::debug!(
                "Attempting to get static routes from server. Attempt {i} of {RETRY_ATTEMPTS}"
            );

            let request = reqwest_client
                .post(format!("http://{address}:{port}/api/static_routes"))
                .body("{}".to_string())
                .send()
                .await;
            match request {
                Ok(request) => {
                    routes = Some(request
                    .json::<Vec<String>>()
                    .await
                    .inspect(|text| tracing::debug!("Got static routes: {text:?}"))
                    .context("Failed to parse static routes from the server. Make sure your server function returns Vec<String> with the (default) json encoding")?);
                    break;
                }
                Err(err) => {
                    // If the request fails, try  up to 5 times with a one second delay
                    // If it fails 5 times, return the error
                    if i == RETRY_ATTEMPTS {
                        return Err(err).context("Failed to get static routes from server. Make sure you have a server function at the `/api/static_routes` endpoint that returns Vec<String> of static routes.");
                    }
                    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
                }
            }
        }

        let routes = routes.expect(
            "static routes should exist or an error should have been returned on the last attempt",
        );

        // Create a pool of futures that cache each route
        let mut resolved_routes = routes
            .into_iter()
            .map(|route| async move {
                tracing::info!("Rendering {route} for SSG");

                // For each route, ping the server to force it to cache the response for ssg
                let request = reqwest_client
                    .get(format!("http://{address}:{port}{route}"))
                    .header("Accept", "text/html")
                    .send()
                    .await?;

                // If it takes longer than 30 seconds to resolve the route, log a warning
                let warning_task = tokio::spawn({
                    let route = route.clone();
                    async move {
                        tokio::time::sleep(Duration::from_secs(30)).await;
                        tracing::warn!("Route {route} has been rendering for 30 seconds");
                    }
                });

                // Wait for the streaming response to completely finish before continuing. We don't use the html it returns directly
                // because it may contain artifacts of intermediate streaming steps while the page is loading. The SSG app should write
                // the final clean HTML to the disk automatically after the request completes.
                let _html = request.text().await?;

                // Cancel the warning task if it hasn't already run
                warning_task.abort();

                Ok::<_, reqwest::Error>(route)
            })
            .collect::<FuturesUnordered<_>>();

        while let Some(route) = resolved_routes.next().await {
            match route {
                Ok(route) => tracing::debug!("ssg success: {route:?}"),
                Err(err) => tracing::error!("ssg error: {err:?}"),
            }
        }

        tracing::info!("SSG complete");

        drop(_child);

        Ok(())
    }
}
