//! One-action setup for the browser view.
//!
//! The pane used to answer "Playwright isn't installed" with a sentence of npm
//! commands and leave the user to it. That went badly, and every part of how it
//! went badly is a constraint on this module:
//!
//!  * `claude mcp add … npx @playwright/mcp@latest` looks like installing
//!    Playwright and is not — see [`super::detect`] — and it can never satisfy
//!    this pane on its own, because the viewer lives in `@playwright/cli`.
//!  * `sudo npm i -g playwright` is what people try next. It works, but the
//!    image leaves npm's prefix at `/usr`, so the unprivileged form fails with
//!    `EACCES` first.
//!  * `playwright install chromium` downloads a browser that then cannot start,
//!    because the image shipped **none** of Chromium's shared libraries
//!    (`libnss3`, `libgbm1`, `libatk*`, `libasound2`, `libcups2`, …). The
//!    download succeeds, the launch fails, and the error reads like a Playwright
//!    bug. Current base images bake those libraries in (see `container/
//!    Dockerfile`), so this step is now usually a no-op — but a project stays on
//!    the base image it was first built from until someone migrates it, so the
//!    old case is the *normal* case and has to keep working. Hence: check, then
//!    install only if needed, and say which happened.
//!  * Getting from there to a working pane took a long tail of further commands.
//!
//! ## Where the packages go, and why it is `/workspace`
//!
//! `playwright` + `@playwright/cli`, installed **locally into `/workspace`** as
//! `claude`, with `--no-save`.
//!
//! `/workspace` is *not* the user's repository. Project directories are
//! bind-mounted one level down, at `/workspace/{mount_name}` (see
//! `container_config`), so `/workspace` itself is ordinary container storage.
//! That single fact settles the choice:
//!
//!  * **Nothing of the user's is mutated.** `/workspace/node_modules` is not
//!    inside any bind mount, so no host file, no `package.json` and no lockfile
//!    of theirs is touched. `--no-save` is belt and braces for the case where
//!    someone has put a `package.json` at `/workspace` themselves — verified
//!    that an install into a directory without one writes `node_modules` and
//!    nothing else.
//!  * **No sudo.** `/usr/lib/node_modules` is root-owned; `/workspace` is the
//!    `claude` user's own working directory. A setup action that needs no
//!    privilege escalation is a setup action with one fewer way to fail.
//!  * **Node can actually find it.** A global install is *not* on the module
//!    resolution path — `require('playwright')` from a script in
//!    `/workspace/my-project` does not see `/usr/lib/node_modules`, but it does
//!    walk up to `/workspace/node_modules`. Since the whole point is for Claude
//!    to drive a browser from a script in the project, this is the difference
//!    between setup that works and setup that merely reports as complete.
//!  * **It hoists.** A local install puts `playwright-core` at the top of the
//!    tree where the probe finds it directly; `npm i -g` leaves it nested (see
//!    the note in [`super::detect`]).
//!  * **It persists.** `/workspace` outside the bind mounts rides the project's
//!    snapshot image across container recreation, and migration copies the
//!    non-bind-mounted parts of `/workspace` forward.
//!
//! Browsers are the exception and are downloaded as `claude` into
//! `~/.cache/ms-playwright`, inside the home volume — so they survive
//! recreation *and* base-image migration, and are lost only on a project Reset.
//! That is worth saying in the UI: it is the difference between a
//! several-hundred-megabyte download once and one every time.

use std::collections::VecDeque;
use std::time::Duration;

use futures_util::StreamExt;
use serde::Serialize;
use tauri::AppHandle;

use crate::commands::project_commands::emit_progress;
use crate::docker::exec::{
    create_attached_exec_as, exec_oneshot_as, wait_for_exec_exit, AttachedExec,
};

use super::detect::{self, PlaywrightDetection};

/// The viewer package — installed **first**, and it decides the version of
/// `playwright` installed after it.
///
/// `@playwright/mcp` is deliberately not part of the set: it is Claude's MCP
/// configuration to make, and it contributes nothing to serving a viewer.
///
/// **Order matters here, and `playwright` is deliberately not `@latest`.**
///
/// Installing both at `@latest` produces a tree that looks right and is broken.
/// Verified on a real container: `@playwright/cli@0.1.18` pins
/// `playwright-core@1.63.0-alpha`, npm hoists that to the root, and
/// `playwright@latest` (1.62.1) then nests its own `playwright-core@1.62.1`
/// beside it. The two cores want *different browser revisions*. The browser
/// step runs the resolved — hoisted — CLI, so it downloads 1237; every script
/// Claude writes says `require("playwright")`, gets the nested 1.62.1, and dies
/// with "Executable doesn't exist … chromium_headless_shell-1234". The pane
/// meanwhile reports a browser installed, because one is.
///
/// So the viewer package goes first and its own pinned `playwright` version is
/// what gets installed second — one core, one browser revision, both halves
/// agreeing. See [`pinned_playwright_spec`].
pub const VIEWER_PACKAGE: &str = "@playwright/cli@latest";

/// Fallback when the viewer's manifest can't be read: better a possibly-skewed
/// tree than no Playwright at all, and [`detect`](super::detect) reports the
/// skew either way.
pub const PLAYWRIGHT_FALLBACK: &str = "playwright@latest";

/// Where the packages are installed. Container storage, not a bind mount — see
/// the module docs.
pub const INSTALL_DIR: &str = "/workspace";

/// npm reaching the registry and unpacking two packages. Minutes, not hours.
const NPM_TIMEOUT: Duration = Duration::from_secs(10 * 60);
/// `apt-get update` plus a dozen library packages, or Google's apt repository.
const DEPS_TIMEOUT: Duration = Duration::from_secs(20 * 60);
/// `apt-get install -s` over ~100 already-installed packages. Local work; a
/// container that cannot answer this in two minutes gets the libraries
/// installed rather than a hang.
const DEPS_CHECK_TIMEOUT: Duration = Duration::from_secs(2 * 60);
/// The browser download itself, on a bad connection.
const BROWSER_TIMEOUT: Duration = Duration::from_secs(45 * 60);
/// Starting a headless browser, and one page load.
const VERIFY_TIMEOUT: Duration = Duration::from_secs(120);

/// Lines of command output kept for the result. Enough to carry npm's actual
/// error, bounded so a chatty download can't grow without limit.
const LOG_LINES: usize = 200;

/// Longest run of bytes without a line break still treated as one progress
/// line. npm's progress output is `\r`-driven and can go a long way.
const MAX_PARTIAL: usize = 4 * 1024;

/// Marks the verdict in the launch check's output, for the same reason
/// [`super::detect`] marks its payload: the stream also carries whatever
/// Chromium felt like writing to stderr.
const LAUNCH_MARKER: &str = "__TRIPLE_C_BROWSER_LAUNCH__";

/// URL the launch check navigates to once a browser is up. The registry is the
/// one host we know the container just reached, during the npm step — so a
/// failure here is informative rather than ambient.
const REACHABILITY_URL: &str = "https://registry.npmjs.org/-/ping";

/// Which browser to install. Both are legitimate; they serve different callers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrowserTarget {
    /// Playwright's own build. What `chromium.launch()` uses with no `channel`,
    /// i.e. the default for scripts the user or Claude writes.
    Chromium,
    /// Google Chrome from Google's apt repository. **`@playwright/mcp` asks for
    /// the `chrome` channel specifically**, so anyone driving the browser
    /// through the MCP plugin needs this one rather than (or as well as) the
    /// bundled build.
    Chrome,
}

impl BrowserTarget {
    pub fn parse(s: &str) -> Result<Self, String> {
        match s {
            "chromium" => Ok(Self::Chromium),
            "chrome" => Ok(Self::Chrome),
            other => Err(format!(
                "Unknown browser '{}'. This pane installs 'chromium' (Playwright's own build) \
                 or 'chrome' (the Google Chrome channel that @playwright/mcp asks for).",
                other
            )),
        }
    }

    /// The name Playwright's CLI knows it by.
    pub fn cli_name(self) -> &'static str {
        match self {
            Self::Chromium => "chromium",
            Self::Chrome => "chrome",
        }
    }

    /// Shown *before* the click. Size first, because the honest failure mode
    /// here is a user who did not know they were starting a large download.
    pub fn download_note(self) -> &'static str {
        match self {
            Self::Chromium => {
                "Playwright's Chromium build — a few hundred MB, a few minutes on a normal \
                 connection. The system libraries it needs are already in current base images; \
                 on an older container they are installed first"
            }
            Self::Chrome => {
                "Google Chrome from Google's apt repository, with its dependencies — roughly \
                 150 MB"
            }
        }
    }

    /// Who needs it, in the user's terms.
    pub fn needed_for(self) -> &'static str {
        match self {
            Self::Chromium => "Playwright scripts that call `chromium.launch()` with no channel",
            Self::Chrome => "`@playwright/mcp`, which asks for the `chrome` channel",
        }
    }

    /// Every `channel` a launch check must pass, comma-separated, where
    /// `default` means "no channel — the bundled build".
    ///
    /// Chromium is checked twice because the two consumers of this install do
    /// not launch the same binary. A script calling `chromium.launch()` with
    /// no channel gets `chromium-headless-shell`; the viewer reads
    /// `~/.playwright/cli.config.json`, which pins channel
    /// `chrome-for-testing`, and that resolves to the *full* `chromium-<rev>`
    /// build — a separate download under the same `install chromium`.
    ///
    /// Checking only the first is how a container reaches "verified" and then
    /// fails in the pane with `Browser "chrome-for-testing" is not installed`.
    /// Observed on a real project, where a stale `chromium-1217` satisfied the
    /// headless-shell launch while the viewer wanted `chromium-1237`.
    fn channels(self) -> &'static str {
        match self {
            Self::Chromium => "default,chrome-for-testing",
            Self::Chrome => "chrome",
        }
    }
}

/// What a setup step did, handed back to the pane so it can update itself
/// without the user reopening the tab.
#[derive(Debug, Clone, Serialize)]
pub struct BrowserSetupOutcome {
    /// A fresh probe, run after the step. This is what makes the pane
    /// self-updating.
    pub detection: PlaywrightDetection,
    /// Tail of the actual command output — always populated, success or not.
    pub log: String,
    /// Verdict of a real headless launch. `None` when the step didn't try one
    /// (the npm step doesn't), `Some(false)` when a browser is installed and
    /// still would not start.
    pub browser_launched: Option<bool>,
    /// A step that failed, or succeeded suspiciously, without failing the whole
    /// action — the user is told rather than left to find out at launch time.
    pub warning: Option<String>,
}

/// Install `playwright` and `@playwright/cli` into `/workspace`. Deliberately
/// does *not* fetch browsers: that is the second step, and its size is stated
/// before it is offered.
pub async fn install_packages(
    app: &AppHandle,
    project_id: &str,
    container_id: &str,
) -> Result<BrowserSetupOutcome, String> {
    emit_progress(
        app,
        project_id,
        &format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR),
    );

    let mut step = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE]).await?;
    if step.exit_code != 0 {
        return Err(format!(
            "npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}",
            step.exit_code,
            step.log_or("it produced no output at all")
        ));
    }

    // Second, `playwright` at the version the viewer package pins — see
    // `VIEWER_PACKAGE`. Installing it as `@latest` is what splits the tree.
    //
    // The viewer package is named *again* here. It is already installed, so
    // this adds no work, but omitting it is what made npm prune it back out —
    // see the note on `npm_install`. The pin can only be read after the first
    // install has written the manifest, which is why this stays two commands
    // rather than one.
    let spec = pinned_playwright_spec(container_id).await;
    emit_progress(app, project_id, &format!("Installing {}…", spec));
    let second = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE, &spec]).await?;
    if second.exit_code != 0 {
        return Err(format!(
            "npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}",
            spec,
            second.exit_code,
            second.log_or("it produced no output at all")
        ));
    }
    step.log = merge_logs(step.log, second.log);

    emit_progress(app, project_id, "Re-checking what the container has…");
    let detection = detect::detect(container_id).await?;

    // A probe that still finds something missing after a successful npm run is
    // the interesting case, so it is surfaced rather than swallowed. And a
    // container with the packages but no browser is *not* finished setup —
    // saying so here is what stops someone walking away from a pane that will
    // never show them anything.
    let mut warning = detection.blocker();
    // Skew outranks "no browser": a container in that state *has* browsers, and
    // telling someone to install one they can see already installed is how a
    // real user ends up doing it three times.
    if let Some(skew) = detection.skew_message() {
        warning = merge(warning, skew);
    } else if detection.needs_browser() {
        warning = merge(
            warning,
            "Playwright is installed, but this container has no browser to drive yet. Install \
             one below — that is the large download, and it is a separate step on purpose."
                .to_string(),
        );
    }

    Ok(BrowserSetupOutcome {
        warning,
        detection,
        log: step.log,
        browser_launched: None,
    })
}

/// One `npm install` of one or more specs, into [`INSTALL_DIR`], as `claude`.
///
/// `env VAR=… cmd` rather than an exec env: it keeps the one exec path in
/// `docker/exec.rs` untouched, and `env` is a real binary so no shell is
/// involved. The guard matters because these are `@latest`: current Playwright
/// has no postinstall (verified — `playwright@1.62.1` declares no `scripts` at
/// all), but if a future release brings the browser download back, this step
/// must stay small and the download must stay the step the user asked for.
///
/// **Every package that must survive has to appear in `specs`.** `--no-save`
/// in a directory with no `package.json` — which [`INSTALL_DIR`] is — leaves
/// npm with the command line as its only statement of what the tree should
/// contain, and npm ≥7 reconciles the tree against that on every run by
/// removing whatever it now considers extraneous. Installing `@playwright/cli`
/// and then installing `playwright` in a second command therefore *deletes the
/// first one*: verified in a container, `removed 3 packages`, leaving an empty
/// `node_modules/@playwright/` behind `playwright` and `playwright-core`. That
/// empty directory is why a fresh setup could report success and still leave
/// the pane saying `@playwright/cli` was not installed.
async fn npm_install(
    app: &AppHandle,
    project_id: &str,
    container_id: &str,
    specs: &[&str],
) -> Result<StepResult, String> {
    let mut cmd = vec![
        "env".to_string(),
        "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1".to_string(),
        "npm".to_string(),
        "install".to_string(),
        // Leaves any package.json and lockfile at /workspace untouched.
        "--no-save".to_string(),
        "--no-fund".to_string(),
        "--no-audit".to_string(),
    ];
    cmd.extend(specs.iter().map(|s| s.to_string()));
    run_step(
        app,
        project_id,
        container_id,
        "claude",
        INSTALL_DIR,
        cmd,
        NPM_TIMEOUT,
    )
    .await
}

/// The `playwright` spec to install: the exact version `@playwright/cli`
/// depends on, so both halves share one `playwright-core`.
///
/// Read from the manifest npm just wrote rather than guessed, and falling back
/// to `@latest` when it can't be read — an unreadable manifest is a reason to
/// install something, not nothing.
async fn pinned_playwright_spec(container_id: &str) -> String {
    let script = format!(
        "try{{const d=require('{}/node_modules/@playwright/cli/package.json').dependencies||{{}};\
         process.stdout.write(d.playwright||'');}}catch(e){{}}",
        INSTALL_DIR
    );
    let (out, _code) = exec_oneshot_as(
        container_id,
        "claude",
        vec!["node".to_string(), "-e".to_string(), script],
        Vec::new(),
    )
    .await
    .unwrap_or_default();

    let version: &str = out.trim();
    // A version, not a range or a URL: anything else goes to the fallback
    // rather than into an npm command line.
    if !version.is_empty()
        && version
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'))
    {
        format!("playwright@{}", version)
    } else {
        PLAYWRIGHT_FALLBACK.to_string()
    }
}

/// Keep both npm runs' output, so a failure in either is diagnosable.
fn merge_logs(first: String, second: String) -> String {
    match (first.trim().is_empty(), second.trim().is_empty()) {
        (true, _) => second,
        (_, true) => first,
        _ => format!("{}\n{}", first.trim_end(), second),
    }
}

/// Install a browser: its system libraries first, then the browser, then prove
/// one actually starts.
///
/// The order is the whole point. `playwright install chromium` on this image
/// downloads a browser that cannot launch, because the libraries it links
/// against are absent — which is why installing Chrome through apt looked like
/// the fix: apt pulls those libraries in as dependencies.
pub async fn install_browser(
    app: &AppHandle,
    project_id: &str,
    container_id: &str,
    target: BrowserTarget,
) -> Result<BrowserSetupOutcome, String> {
    let before = detect::detect(container_id).await?;
    let Some(cli) = before.playwright_cli.clone() else {
        return Err(
            "Playwright isn't installed in this container yet, so there is nothing to install a \
             browser for. Run “Set up Playwright” first."
                .to_string(),
        );
    };

    let mut log = String::new();
    let mut warning: Option<String> = None;

    // Step 1 — system libraries. Slow, previously invisible, and the actual
    // cause of the "Chromium downloads and then dies" reports, so it gets its
    // own progress line rather than being folded into the download.
    //
    // Current base images bake these in, so on an up-to-date container there is
    // nothing to do here. That is *not* a reason to drop the step: a project
    // keeps the base image it was first built from until it is migrated, so
    // containers without the libraries are the common case for a long while
    // yet. So: ask first, skip loudly, install only when the answer is no.
    //
    // The question is put to Playwright rather than answered by probing for
    // library names ourselves. `install-deps --dry-run` simulates the very
    // `apt-get install` that `install-deps` would run and exits non-zero if
    // anything is missing, which means the check and the fix can never disagree
    // about what "the libraries" means — including after a Playwright release
    // adds one.
    //
    // Installing runs `install-deps` directly *as root*: that is what `playwright
    // install --with-deps` does internally, minus Playwright's own privilege
    // escalation (read from the shipped source, it shells out to
    // `sudo -- sh -c "apt-get update && apt-get install …"` when it is not root,
    // which would work here — `claude` has passwordless sudo — but puts an extra
    // failure mode between the user and the answer).
    //
    // For the Chrome channel apt installs `google-chrome-stable`, whose own
    // dependencies cover the same libraries — but going through the same check
    // first makes the two paths behave identically.
    emit_progress(
        app,
        project_id,
        "Step 1/3 — checking whether this container already has the browser system libraries…",
    );
    let state = check_libraries(container_id, &cli, target).await;
    match &state {
        LibraryState::Present => {
            emit_progress(
                app,
                project_id,
                "Step 1/3 — already there: this image ships the browser system libraries. \
                 Skipping the apt install.",
            );
            push_section(&mut log, "Browser system libraries: already installed, apt skipped.");
        }
        LibraryState::Missing(_) | LibraryState::Unknown(_) => {
            emit_progress(
                app,
                project_id,
                &format!(
                    "Step 1/3 — {} Installing browser system libraries with apt (needs root; a \
                     minute or two)…",
                    state.detail()
                ),
            );
            let deps = run_step(
                app,
                project_id,
                container_id,
                "root",
                "/tmp",
                vec![
                    "node".to_string(),
                    cli.clone(),
                    "install-deps".to_string(),
                    target.cli_name().to_string(),
                ],
                DEPS_TIMEOUT,
            )
            .await?;
            push_section(&mut log, &deps.log);
            if deps.exit_code != 0 {
                // Not fatal on its own — some of the libraries may already be
                // present — but it must never pass silently, because the failure
                // it causes surfaces much later and looks like something else.
                warning = Some(format!(
                    "Installing the browser's system libraries failed (exit {}). The browser may \
                     install and then refuse to start. apt said:\n{}",
                    deps.exit_code,
                    deps.log_or("nothing")
                ));
            }
        }
    }

    // Step 2 — the download the user was warned about. Chromium is fetched as
    // `claude` so the bundle lands in the home volume's `~/.cache/ms-playwright`
    // where the viewer looks for it; the Chrome channel is an apt install and
    // has to be root.
    emit_progress(
        app,
        project_id,
        &format!(
            "Step 2/3 — installing {}, needed for {} — {}…",
            target.cli_name(),
            target.needed_for(),
            target.download_note()
        ),
    );
    let (user, workdir) = match target {
        BrowserTarget::Chromium => ("claude", INSTALL_DIR),
        BrowserTarget::Chrome => ("root", "/tmp"),
    };
    let dl = run_step(
        app,
        project_id,
        container_id,
        user,
        workdir,
        vec![
            "node".to_string(),
            cli,
            "install".to_string(),
            target.cli_name().to_string(),
        ],
        BROWSER_TIMEOUT,
    )
    .await?;
    push_section(&mut log, &dl.log);
    if dl.exit_code != 0 {
        return Err(format!(
            "{} didn't install (exit {}).\n\nPlaywright said:\n{}",
            target.cli_name(),
            dl.exit_code,
            dl.log_or("nothing")
        ));
    }

    // Step 3 — "installed" is not "works". The absence of this check is what
    // turned a missing library into a pile of confusing errors.
    emit_progress(
        app,
        project_id,
        &format!("Step 3/3 — checking that {} actually launches…", target.cli_name()),
    );
    let verdict = verify_launch(container_id, &before, target).await;
    push_section(&mut log, &verdict.detail);
    if !verdict.ok {
        warning = merge(
            warning,
            format!(
                "{} is installed but would not start: {}",
                target.cli_name(),
                verdict.detail.trim()
            ),
        );
    }
    if let Some(cert) = verdict.cert_error {
        // Distinct on purpose. A TLS-intercepting proxy is a container-wide
        // trust-store gap — it breaks npm, git, curl and Claude Code the same
        // way — and telling someone behind one that Playwright is broken sends
        // them to fix the wrong thing. This pane does not install CAs.
        warning = merge(
            warning,
            format!(
                "The browser starts, but HTTPS pages fail certificate validation ({}). That is \
                 this container not trusting your network's certificate authority — a TLS-\
                 intercepting proxy — and it affects everything in the container, not just the \
                 browser. Installing the CA into the container's trust store is the fix; this \
                 pane doesn't do that.",
                cert.trim()
            ),
        );
    }

    emit_progress(app, project_id, "Re-checking what the container has…");
    let detection = detect::detect(container_id).await?;

    Ok(BrowserSetupOutcome {
        detection,
        log: log.trim().to_string(),
        browser_launched: Some(verdict.ok),
        warning,
    })
}

/// Phrases `install-deps --dry-run` prints. Matching on Playwright's own words
/// is load-bearing: the exit code alone cannot tell "everything is installed"
/// (0) apart from "this platform isn't in my table, so I did nothing" (also 0).
const DEPS_OK_MARKER: &str = "All system dependencies are installed";
const DEPS_MISSING_MARKER: &str = "Missing system dependencies";
const DEPS_UNKNOWN_PLATFORM_MARKER: &str = "Cannot install dependencies for";

/// Whether this container already has the libraries a browser links against.
#[derive(Debug, Clone, PartialEq, Eq)]
enum LibraryState {
    /// Playwright confirms every package it would install is present. Current
    /// base images bake them, so this is the answer on an up-to-date container.
    Present,
    /// Playwright named packages that are absent.
    Missing(String),
    /// The check could not answer. Always installs — an unnecessary apt run
    /// costs a minute, a skipped one costs a browser that will not start.
    Unknown(String),
}

impl LibraryState {
    /// What the user sees on the progress line, and why.
    fn detail(&self) -> &str {
        match self {
            Self::Present => "",
            Self::Missing(d) | Self::Unknown(d) => d,
        }
    }
}

/// Ask Playwright whether the libraries are already installed.
///
/// `--dry-run` simulates the same `apt-get install` that `install-deps` would
/// perform and exits non-zero if any package is missing, so the check can never
/// disagree with the fix about what the dependency set is — including after a
/// Playwright release changes it.
///
/// Note that the simulation works on an image whose `/var/lib/apt/lists` has
/// been cleaned (every base image's has): apt knows installed packages from
/// dpkg's status file. A *missing* package on such an image is simply not in
/// any index, so apt fails, Playwright reports the failure, and this returns
/// [`LibraryState::Unknown`] — which installs, which is the right answer.
async fn check_libraries(container_id: &str, cli: &str, target: BrowserTarget) -> LibraryState {
    let run = exec_oneshot_as(
        container_id,
        "root",
        vec![
            "node".to_string(),
            cli.to_string(),
            "install-deps".to_string(),
            "--dry-run".to_string(),
            target.cli_name().to_string(),
        ],
        vec![],
    );
    match tokio::time::timeout(DEPS_CHECK_TIMEOUT, run).await {
        Ok(Ok((output, code))) => classify_library_check(&output, code),
        Ok(Err(e)) => LibraryState::Unknown(format!(
            "Couldn't ask Playwright whether they're already there ({}), so installing them to be \
             sure.",
            e
        )),
        Err(_) => LibraryState::Unknown(
            "The check for them didn't finish in time, so installing them to be sure.".to_string(),
        ),
    }
}

/// Read the verdict out of `install-deps --dry-run`'s output.
fn classify_library_check(output: &str, exit_code: i64) -> LibraryState {
    // Checked before the success marker, not after: this branch also exits 0.
    if output.contains(DEPS_UNKNOWN_PLATFORM_MARKER) {
        return LibraryState::Unknown(
            "Playwright doesn't have a dependency list for this container's platform, so it \
             can't say — installing them to be sure."
                .to_string(),
        );
    }
    if exit_code == 0 && output.contains(DEPS_OK_MARKER) {
        return LibraryState::Present;
    }
    if let Some(idx) = output.find(DEPS_MISSING_MARKER) {
        let summary = output[idx..]
            .lines()
            .next()
            .unwrap_or(DEPS_MISSING_MARKER)
            .trim();
        return LibraryState::Missing(format!("Playwright reports {}", summary.to_lowercase()));
    }
    LibraryState::Unknown(format!(
        "Couldn't tell whether they're already there (the check exited {}), so installing them to \
         be sure.",
        exit_code
    ))
}

/// The result of actually starting a browser.
#[derive(Debug, Clone)]
struct LaunchVerdict {
    ok: bool,
    detail: String,
    /// Set when a page load failed specifically on certificate trust.
    cert_error: Option<String>,
}

/// Launch the browser headless, load one page, and close it. Seconds, and it is
/// the only thing that distinguishes "downloaded" from "usable".
async fn verify_launch(
    container_id: &str,
    detection: &PlaywrightDetection,
    target: BrowserTarget,
) -> LaunchVerdict {
    // The module directory of whatever Playwright the probe resolved, passed in
    // the environment rather than interpolated into the script, so no path can
    // ever be read as JavaScript.
    let Some(manifest) = detection.playwright_path.as_deref() else {
        return LaunchVerdict {
            ok: false,
            detail: "Playwright could not be located to test with.".to_string(),
            cert_error: None,
        };
    };
    let dir = manifest.trim_end_matches("package.json").trim_end_matches('/');

    let run = exec_oneshot_as(
        container_id,
        "claude",
        vec![
            "node".to_string(),
            "-e".to_string(),
            LAUNCH_PROBE.to_string(),
        ],
        vec![
            format!("TRIPLE_C_PW_DIR={}", dir),
            format!("TRIPLE_C_PW_CHANNELS={}", target.channels()),
            format!("TRIPLE_C_PW_URL={}", REACHABILITY_URL),
        ],
    );
    match tokio::time::timeout(VERIFY_TIMEOUT, run).await {
        Ok(Ok((output, _))) => parse_launch_output(&output),
        Ok(Err(e)) => LaunchVerdict {
            ok: false,
            detail: e,
            cert_error: None,
        },
        Err(_) => LaunchVerdict {
            ok: false,
            detail: "the launch check didn't finish in time — treat the browser as unproven"
                .to_string(),
            cert_error: None,
        },
    }
}

/// Pull the verdict out of the launch check's combined output.
fn parse_launch_output(output: &str) -> LaunchVerdict {
    let Some(idx) = output.find(LAUNCH_MARKER) else {
        let trimmed = output.trim();
        return LaunchVerdict {
            ok: false,
            detail: if trimmed.is_empty() {
                "the launch check produced no output".to_string()
            } else {
                trimmed.lines().next_back().unwrap_or(trimmed).trim().to_string()
            },
            cert_error: None,
        };
    };
    let line = output[idx + LAUNCH_MARKER.len()..]
        .lines()
        .next()
        .unwrap_or("")
        .trim();
    let value: serde_json::Value = match serde_json::from_str(line) {
        Ok(v) => v,
        Err(e) => {
            return LaunchVerdict {
                ok: false,
                detail: format!("unreadable launch check result: {}", e),
                cert_error: None,
            }
        }
    };

    let ok = value.get("ok").and_then(|b| b.as_bool()).unwrap_or(false);
    let detail = value
        .get("detail")
        .and_then(|d| d.as_str())
        .unwrap_or("")
        .to_string();
    let nav = value.get("nav");
    let cert_error = nav
        .filter(|n| n.get("cert").and_then(|c| c.as_bool()).unwrap_or(false))
        .and_then(|n| n.get("detail").and_then(|d| d.as_str()))
        .map(|s| s.to_string());

    let detail = if !ok {
        detail
    } else {
        match nav.and_then(|n| n.get("ok").and_then(|o| o.as_bool())) {
            // A launch that works and a page that loads: setup is genuinely done.
            Some(true) => format!("Browser launched ({}) and loaded a page.", detail),
            // Launch fine, page not. Cert failures are reported separately; any
            // other cause is likely just an offline container, so it is stated
            // without being turned into an alarm.
            Some(false) => format!(
                "Browser launched ({}), but couldn't load a test page: {}",
                detail,
                nav.and_then(|n| n.get("detail").and_then(|d| d.as_str()))
                    .unwrap_or("no detail")
            ),
            _ => format!("Browser launched ({}).", detail),
        }
    };

    LaunchVerdict {
        ok,
        detail,
        cert_error,
    }
}

/// The launch check. One `argv` element, no newlines, same contract as the
/// detection probe.
///
/// `chromiumSandbox` is set explicitly rather than left to Playwright's
/// default, so this check states the same thing the seeded
/// `cli.config.json` does instead of agreeing with it by coincidence. The
/// containers forbid unprivileged user namespaces, so a sandboxed Chromium
/// aborts on launch; nothing here should be able to drift back into testing a
/// configuration the viewer will not use.
///
/// Each channel in `TRIPLE_C_PW_CHANNELS` is launched in turn — see
/// [`BrowserTarget::channels`] for why Chromium needs two — and a failure
/// names the channel that failed, because "is not installed" is meaningless
/// without it. Only the last launch loads a page: the navigation is
/// best-effort, never decides `ok`, and exists to tell a TLS-intercepted
/// network apart from a broken install, so doing it once is enough.
///
/// The timeout exists so a browser that hangs on a missing library still
/// returns a verdict rather than sitting there until the exec is torn down.
const LAUNCH_PROBE: &str = concat!(
    r#"const d=process.env.TRIPLE_C_PW_DIR,chs=process.env.TRIPLE_C_PW_CHANNELS||"default",u=process.env.TRIPLE_C_PW_URL;"#,
    r#"let done=false;const say=(ok,detail,nav)=>{if(done)return;done=true;"#,
    r#"process.stdout.write("\n__TRIPLE_C_BROWSER_LAUNCH__"+JSON.stringify({ok,detail,nav:nav||null})+"\n");};"#,
    r#"const one=(e)=>String((e&&e.message)||e).split("\n").slice(0,8).join(" | ");"#,
    r#"const t=setTimeout(()=>{say(false,"the browser did not finish starting within 90s");process.exit(0);},90000);"#,
    r#"(async()=>{let b=null,cur="";try{const {chromium}=require(d);"#,
    r#"const list=chs.split(",").map(s=>s.trim()).filter(Boolean);"#,
    r#"let v="",nav={ok:true,cert:false,detail:""};"#,
    r#"for(let i=0;i<list.length;i++){cur=list[i];const c=cur==="default"?undefined:cur;"#,
    r#"b=await chromium.launch(Object.assign({chromiumSandbox:false},c?{channel:c}:{}));"#,
    r#"try{v=b.version();}catch(e){}"#,
    r#"if(i===list.length-1){"#,
    r#"try{const p=await b.newPage();await p.goto(u,{timeout:20000});}"#,
    // A certificate failure is classified here, next to the message, because
    // Chromium's wording is the only place the distinction exists.
    r#"catch(e){const m=one(e);nav={ok:false,cert:/ERR_CERT|CERT_AUTHORITY|ERR_SSL|SSL_ERROR|self.signed/i.test(m),detail:m};}}"#,
    r#"await b.close();b=null;}"#,
    r#"clearTimeout(t);say(true,v,nav);}"#,
    r#"catch(e){clearTimeout(t);try{if(b)await b.close();}catch(e2){}"#,
    r#"say(false,(cur&&cur!=="default"?"channel "+cur+": ":"")+one(e));}"#,
    r#"process.exit(0);})();"#,
);

/// One command's result: its exit code and the tail of what it printed.
struct StepResult {
    exit_code: i64,
    log: String,
}

impl StepResult {
    fn log_or<'a>(&'a self, fallback: &'a str) -> &'a str {
        if self.log.trim().is_empty() {
            fallback
        } else {
            &self.log
        }
    }
}

/// Run one command in the container, streaming every line it prints to the pane
/// as `container-progress` and keeping the tail for the result.
///
/// Uses `create_attached_exec_as` — the single attached-exec path — rather than
/// `exec_oneshot`, because these commands run for minutes and the point is that
/// the user can watch them.
async fn run_step(
    app: &AppHandle,
    project_id: &str,
    container_id: &str,
    user: &str,
    workdir: &str,
    cmd: Vec<String>,
    limit: Duration,
) -> Result<StepResult, String> {
    let AttachedExec {
        exec_id,
        mut output,
        input,
    } = create_attached_exec_as(container_id, cmd, false, user, workdir).await?;

    // Nothing is ever written to this exec. Closing stdin means anything that
    // would prompt sees EOF and gives up, instead of waiting for a person who
    // isn't there.
    drop(input);

    let mut tail: VecDeque<String> = VecDeque::with_capacity(LOG_LINES);
    let mut partial = String::new();

    let pump = async {
        while let Some(msg) = output.next().await {
            let data = msg.map_err(|e| format!("Lost the container's output: {}", e))?;
            let chunk = String::from_utf8_lossy(&data.into_bytes()).into_owned();
            // npm and Playwright both redraw with `\r`; treating that as a line
            // break is what turns a progress bar into progress.
            for piece in chunk.split_inclusive(|c| c == '\n' || c == '\r') {
                partial.push_str(piece);
                if piece.ends_with('\n') || piece.ends_with('\r') || partial.len() >= MAX_PARTIAL {
                    let line = std::mem::take(&mut partial);
                    record(&mut tail, line.trim(), app, project_id);
                }
            }
        }
        Ok::<(), String>(())
    };

    let outcome = tokio::time::timeout(limit, pump).await;
    if !partial.trim().is_empty() {
        let line = std::mem::take(&mut partial);
        record(&mut tail, line.trim(), app, project_id);
    }
    let log = tail.iter().cloned().collect::<Vec<_>>().join("\n");

    match outcome {
        // The captured tail is deliberately part of the timeout message: a step
        // that ran for 45 minutes and stopped has its reason in its last lines.
        Err(_) => Err(format!(
            "The command didn't finish within {} minutes and was abandoned.\n\nLast output:\n{}",
            limit.as_secs() / 60,
            if log.trim().is_empty() { "none" } else { &log }
        )),
        Ok(Err(e)) => Err(e),
        Ok(Ok(())) => Ok(StepResult {
            exit_code: wait_for_exec_exit(&exec_id).await.unwrap_or(0),
            log,
        }),
    }
}

/// Keep a line for the result and show it in the pane.
fn record(tail: &mut VecDeque<String>, line: &str, app: &AppHandle, project_id: &str) {
    if line.is_empty() {
        return;
    }
    if tail.len() == LOG_LINES {
        tail.pop_front();
    }
    tail.push_back(line.to_string());
    emit_progress(app, project_id, &truncate(line, 160));
}

/// Append a further command's output to the accumulated log.
fn push_section(log: &mut String, section: &str) {
    if section.trim().is_empty() {
        return;
    }
    if !log.is_empty() {
        log.push_str("\n\n");
    }
    log.push_str(section.trim());
}

/// Combine warnings so a second one never silently replaces the first.
fn merge(existing: Option<String>, next: String) -> Option<String> {
    Some(match existing {
        Some(prev) => format!("{}\n\n{}", prev, next),
        None => next,
    })
}

/// Progress is a single line in the UI, so an over-long one is cut here rather
/// than allowed to reflow the layout. Cuts on a char boundary.
fn truncate(line: &str, max: usize) -> String {
    if line.chars().count() <= max {
        return line.to_string();
    }
    let mut s: String = line.chars().take(max.saturating_sub(1)).collect();
    s.push('…');
    s
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_package_set_is_the_minimum_that_satisfies_the_probe() {
        // The viewer package is not optional, and `@playwright/mcp` is not a
        // member: it can bind sessions, it can never serve the UI.
        assert!(VIEWER_PACKAGE.starts_with("@playwright/cli@"));
        assert!(PLAYWRIGHT_FALLBACK.starts_with("playwright@"));
        assert!(!VIEWER_PACKAGE.contains("@playwright/mcp"));
    }

    #[test]
    fn playwright_is_not_installed_at_latest_alongside_the_viewer() {
        // `@latest` for both is exactly what splits the tree into two
        // `playwright-core`s wanting different browser revisions — the viewer
        // green, every `require("playwright")` dead. The version comes from the
        // viewer's own manifest instead; `@latest` is only the fallback for an
        // unreadable one.
        assert!(!VIEWER_PACKAGE.contains("playwright@latest"));
        assert_eq!(PLAYWRIGHT_FALLBACK, "playwright@latest");
    }

    #[test]
    fn packages_are_installed_where_no_sudo_and_no_bind_mount_are_involved() {
        // Bind mounts live at /workspace/<mount_name>; /workspace itself does
        // not belong to the user's repository, and does belong to `claude`.
        assert_eq!(INSTALL_DIR, "/workspace");
    }

    #[test]
    fn both_browser_targets_are_offered_and_named_for_who_needs_them() {
        assert_eq!(BrowserTarget::parse("chromium").unwrap(), BrowserTarget::Chromium);
        assert_eq!(BrowserTarget::parse("chrome").unwrap(), BrowserTarget::Chrome);
        // `@playwright/mcp` asks for the chrome channel specifically, so the UI
        // must be able to say so.
        assert!(BrowserTarget::Chrome.needed_for().contains("@playwright/mcp"));
        assert_eq!(BrowserTarget::Chrome.channels(), "chrome");
        // Both of Chromium's consumers, or the check passes for a browser the
        // viewer cannot open — see `channels`.
        assert_eq!(BrowserTarget::Chromium.channels(), "default,chrome-for-testing");
        // And a size, before the click, for both.
        for t in [BrowserTarget::Chromium, BrowserTarget::Chrome] {
            assert!(t.download_note().to_lowercase().contains("mb"), "{:?}", t);
        }
    }

    #[test]
    fn an_unknown_browser_is_refused_with_the_two_that_work() {
        let err = BrowserTarget::parse("firefox").unwrap_err();
        assert!(err.contains("chromium"), "{}", err);
        assert!(err.contains("chrome"), "{}", err);
    }

    #[test]
    fn baked_in_libraries_are_detected_and_the_apt_step_is_skipped() {
        // What a container built from a current base image says. The whole
        // point of baking them: this must not re-run apt.
        assert_eq!(
            classify_library_check("All system dependencies are installed.\n", 0),
            LibraryState::Present
        );
    }

    #[test]
    fn an_older_image_without_them_is_detected_and_named() {
        let v = classify_library_check(
            "Missing system dependencies (12):\n  libnss3\n  libgbm1\n",
            1,
        );
        match v {
            LibraryState::Missing(d) => assert!(d.contains("(12)"), "{}", d),
            other => panic!("expected Missing, got {:?}", other),
        }
    }

    #[test]
    fn an_unrecognised_platform_installs_rather_than_reporting_success() {
        // Playwright prints this and exits **0** having installed nothing, so a
        // check that trusted the exit code would skip the apt step on exactly
        // the container that needs it.
        let v = classify_library_check(
            "Cannot install dependencies for ubuntu24.04-riscv64 with Playwright 1.62.1!\n",
            0,
        );
        assert!(matches!(v, LibraryState::Unknown(_)), "{:?}", v);
    }

    #[test]
    fn a_check_that_could_not_run_installs_rather_than_guessing() {
        // e.g. apt cannot resolve a package because the image's package index
        // was cleaned and the package is genuinely absent.
        let v = classify_library_check("E: Unable to locate package libgbm1\n", 100);
        match v {
            LibraryState::Unknown(d) => assert!(d.contains("100"), "{}", d),
            other => panic!("expected Unknown, got {:?}", other),
        }
        // Present contributes nothing to the progress line; the other two must
        // explain themselves, because the reason is shown to the user.
        assert_eq!(LibraryState::Present.detail(), "");
        assert!(!LibraryState::Unknown("why".into()).detail().is_empty());
    }

    #[test]
    fn a_successful_launch_and_page_load_is_reported_as_working() {
        let v = parse_launch_output(concat!(
            "some chromium noise\n__TRIPLE_C_BROWSER_LAUNCH__",
            r#"{"ok":true,"detail":"140.0.1","nav":{"ok":true,"cert":false,"detail":""}}"#,
            "\n",
        ));
        assert!(v.ok);
        assert!(v.cert_error.is_none());
        assert!(v.detail.contains("140.0.1"), "{}", v.detail);
        assert!(v.detail.contains("loaded a page"), "{}", v.detail);
    }

    #[test]
    fn a_missing_shared_library_is_reported_verbatim() {
        // The exact failure the base image produces. It must reach the user as
        // itself, not as "installation failed".
        let v = parse_launch_output(concat!(
            "__TRIPLE_C_BROWSER_LAUNCH__",
            r#"{"ok":false,"detail":"Host system is missing dependencies: libnss3.so"}"#,
            "\n",
        ));
        assert!(!v.ok);
        assert!(v.detail.contains("libnss3.so"), "{}", v.detail);
    }

    #[test]
    fn a_tls_intercepting_proxy_is_distinguished_from_a_broken_install() {
        // The browser is fine; the container doesn't trust the network's CA.
        // Reporting this as a launch failure sends the user to fix Playwright.
        let v = parse_launch_output(concat!(
            "__TRIPLE_C_BROWSER_LAUNCH__",
            r#"{"ok":true,"detail":"140.0.1","nav":{"ok":false,"cert":true,"#,
            r#""detail":"page.goto: net::ERR_CERT_AUTHORITY_INVALID at https://registry.npmjs.org/"}}"#,
            "\n",
        ));
        assert!(v.ok, "the browser did launch");
        assert_eq!(
            v.cert_error.as_deref().map(|s| s.contains("ERR_CERT_AUTHORITY_INVALID")),
            Some(true)
        );
    }

    #[test]
    fn an_offline_container_is_not_reported_as_a_certificate_problem() {
        let v = parse_launch_output(concat!(
            "__TRIPLE_C_BROWSER_LAUNCH__",
            r#"{"ok":true,"detail":"140.0.1","nav":{"ok":false,"cert":false,"#,
            r#""detail":"net::ERR_NAME_NOT_RESOLVED"}}"#,
            "\n",
        ));
        assert!(v.ok);
        assert!(v.cert_error.is_none());
        assert!(v.detail.contains("ERR_NAME_NOT_RESOLVED"), "{}", v.detail);
    }

    #[test]
    fn an_unmarked_stream_surfaces_the_containers_own_error() {
        let v = parse_launch_output("node: command not found\n");
        assert!(!v.ok);
        assert!(v.detail.contains("command not found"), "{}", v.detail);
    }

    #[test]
    fn an_empty_stream_is_explained_rather_than_parsed() {
        let v = parse_launch_output("  \n");
        assert!(!v.ok);
        assert!(v.detail.contains("no output"), "{}", v.detail);
    }

    #[test]
    fn the_launch_probe_is_a_single_argv_element() {
        assert!(!LAUNCH_PROBE.contains('\n'));
        assert!(LAUNCH_PROBE.contains(LAUNCH_MARKER));
        // Paths and channels are read from the environment, never interpolated.
        assert!(LAUNCH_PROBE.contains("process.env.TRIPLE_C_PW_DIR"));
        assert!(LAUNCH_PROBE.contains("process.env.TRIPLE_C_PW_CHANNEL"));
        assert!(LAUNCH_PROBE.contains("ERR_CERT"));
    }

    #[test]
    fn warnings_accumulate_rather_than_overwrite() {
        let w = merge(Some("first".to_string()), "second".to_string()).unwrap();
        assert!(w.contains("first") && w.contains("second"), "{}", w);
        assert_eq!(merge(None, "only".to_string()).as_deref(), Some("only"));
    }

    #[test]
    fn long_progress_lines_are_cut_on_a_char_boundary() {
        let line = "é".repeat(400);
        let cut = truncate(&line, 160);
        assert_eq!(cut.chars().count(), 160);
        assert!(cut.ends_with('…'));
        assert_eq!(truncate("short", 160), "short");
    }
}
