//! Single source of truth for the sandbox user-local CLI/npm environment
//! that's deployed to `/sandbox/.right/env.sh`, sourced from `.bashrc`,
//! and inlined as a fallback into per-invocation prompt-assembly scripts.
//!
//! Used by:
//! - `crate::sync` — writes `env.sh` and patches `.bashrc`
//! - `crate::cc::prompt` — inlines fallback into the prompt-assembly script
//! - `crate::telegram::worker` — inlines fallback into the background-review SSH script
//!
//! Drift between these sites silently breaks the agent contract; do not
//! duplicate the strings elsewhere.

pub(crate) const SANDBOX_ROOT: &str = "/sandbox";
pub(crate) const SANDBOX_ENV_PATH: &str = "/sandbox/.right/env.sh";
pub(crate) const SANDBOX_ENV_DIR: &str = "/sandbox/.right";
pub(crate) const SANDBOX_LOCAL_PREFIX: &str = "/sandbox/.local";
pub(crate) const SANDBOX_LOCAL_BIN: &str = "/sandbox/.local/bin";
pub(crate) const SANDBOX_NPM_CACHE: &str = "/sandbox/.npm";
pub(crate) const SANDBOX_BASHRC_PATH: &str = "/sandbox/.bashrc";
pub(crate) const MANAGED_ENV_START_MARKER: &str = "# >>> RIGHT_AGENT managed env >>>";
pub(crate) const MANAGED_ENV_END_MARKER: &str = "# <<< RIGHT_AGENT managed env <<<";

/// Body of `/sandbox/.right/env.sh`. Quoted-value form is authoritative;
/// the inline fallback below mirrors it without quotes (POSIX-equivalent).
pub(crate) fn env_file_content() -> String {
    format!(
        r#"# Generated by Right Agent. Do not edit.
export RIGHT_AGENT_MANAGED_ENV=1
mkdir -p {SANDBOX_LOCAL_BIN} {SANDBOX_NPM_CACHE}
case ":$PATH:" in
  *:{SANDBOX_LOCAL_BIN}:*) ;;
  *) export PATH="{SANDBOX_LOCAL_BIN}:$PATH" ;;
esac
export NPM_CONFIG_PREFIX="{SANDBOX_LOCAL_PREFIX}"
export NPM_CONFIG_CACHE="{SANDBOX_NPM_CACHE}"
"#
    )
}

/// Managed block sourced from `.bashrc`. Delimited by the markers above
/// so `crate::sync::ensure_bashrc_sources_managed_env` can replace it
/// idempotently.
pub(crate) fn bashrc_source_block() -> &'static str {
    r#"# >>> RIGHT_AGENT managed env >>>
if [ -f /sandbox/.right/env.sh ]; then
  . /sandbox/.right/env.sh
fi
# <<< RIGHT_AGENT managed env <<<
"#
}

/// Inlined into prompt-assembly and background-review SSH scripts. Sources
/// `env.sh` when present; otherwise sets the same contract directly so a
/// missed deployment doesn't break `claude -p` tools.
pub(crate) const INLINE_FALLBACK_SCRIPT: &str = r#"if [ -f /sandbox/.right/env.sh ]; then
  . /sandbox/.right/env.sh
else
  mkdir -p /sandbox/.local/bin /sandbox/.npm
  case ":$PATH:" in
    *:/sandbox/.local/bin:*) ;;
    *) export PATH="/sandbox/.local/bin:$PATH" ;;
  esac
  export NPM_CONFIG_PREFIX=/sandbox/.local
  export NPM_CONFIG_CACHE=/sandbox/.npm
fi
"#;

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

    /// Lock the env.sh contract to the constants: any change to paths
    /// without a matching shell-string change should fail here.
    #[test]
    fn env_file_content_uses_const_paths() {
        let content = env_file_content();
        assert!(content.contains(SANDBOX_LOCAL_BIN));
        assert!(content.contains(SANDBOX_NPM_CACHE));
        assert!(content.contains("RIGHT_AGENT_MANAGED_ENV=1"));
    }

    /// Inline fallback must reference the same paths as env.sh — otherwise
    /// a missing env.sh deployment yields a silently different environment.
    /// Fast smoke check; the semantic-drift gate is
    /// `inline_fallback_and_env_file_export_same_env`.
    #[test]
    fn inline_fallback_references_same_paths_as_env_file() {
        assert!(INLINE_FALLBACK_SCRIPT.contains(SANDBOX_LOCAL_BIN));
        assert!(INLINE_FALLBACK_SCRIPT.contains(SANDBOX_NPM_CACHE));
        assert!(INLINE_FALLBACK_SCRIPT.contains(SANDBOX_ENV_PATH));
    }

    #[test]
    fn bashrc_source_block_is_marker_delimited() {
        let block = bashrc_source_block();
        assert!(block.contains(MANAGED_ENV_START_MARKER));
        assert!(block.contains(MANAGED_ENV_END_MARKER));
        assert!(block.contains(SANDBOX_ENV_PATH));
    }

    /// Run both shell snippets through `bash` and assert that the resulting
    /// `PATH` prepend, `NPM_CONFIG_PREFIX`, and `NPM_CONFIG_CACHE` match
    /// byte-for-byte. This catches semantic drift the string-`contains`
    /// smoke test cannot — e.g. one snippet quoting a value the other does
    /// not, or one snippet dropping an export entirely.
    ///
    /// Both snippets reference `/sandbox/...` paths the host typically
    /// cannot create. We rewrite both to use a tempdir so `mkdir -p`
    /// succeeds, while keeping the relative structure identical. The
    /// fallback's `if [ -f .../env.sh ]; then ... fi` is forced into the
    /// else branch because the rewritten path under the tempdir does not
    /// exist when the inline-fallback script runs.
    #[test]
    fn inline_fallback_and_env_file_export_same_env() {
        use std::process::Command;

        let tmp = tempfile::tempdir().expect("create tempdir");
        let fake_root = tmp.path().to_str().expect("utf-8 tempdir path");

        let env_script = env_file_content().replace(SANDBOX_ROOT, fake_root);
        let fallback_script = INLINE_FALLBACK_SCRIPT.replace(SANDBOX_ROOT, fake_root);

        // Sanity: the rewritten env-file path must not exist, so the
        // fallback branch is the one that actually runs.
        let rewritten_env_path = SANDBOX_ENV_PATH.replace(SANDBOX_ROOT, fake_root);
        assert!(
            !std::path::Path::new(&rewritten_env_path).exists(),
            "rewritten env path unexpectedly exists: {rewritten_env_path}"
        );

        let dump = "echo PATH=$PATH; \
                    echo NPM_CONFIG_PREFIX=$NPM_CONFIG_PREFIX; \
                    echo NPM_CONFIG_CACHE=$NPM_CONFIG_CACHE";

        let run = |body: &str, label: &str| -> Vec<(String, String)> {
            let full = format!("set -eu\n{body}\n{dump}");
            let out = Command::new("bash")
                .arg("-c")
                .arg(&full)
                .env_clear()
                .env("PATH", "/usr/bin:/bin")
                .env("HOME", fake_root)
                .output()
                .unwrap_or_else(|e| panic!("bash failed to spawn for {label}: {e}"));
            assert!(
                out.status.success(),
                "bash failed for {label}: {}",
                String::from_utf8_lossy(&out.stderr)
            );
            String::from_utf8_lossy(&out.stdout)
                .lines()
                .filter_map(|l| l.split_once('=').map(|(k, v)| (k.to_owned(), v.to_owned())))
                .collect()
        };

        let env_file = run(&env_script, "env_file_content");
        let fallback = run(&fallback_script, "INLINE_FALLBACK_SCRIPT");

        assert_eq!(
            env_file, fallback,
            "env.sh and INLINE_FALLBACK_SCRIPT exported different environments:\n\
             env_file = {env_file:?}\nfallback = {fallback:?}"
        );

        // Independent of equality above, both must actually prepend
        // /sandbox/.local/bin (rewritten under the tempdir) to PATH — a
        // missing prepend would still compare-equal but break the agent.
        let expected_bin = SANDBOX_LOCAL_BIN.replace(SANDBOX_ROOT, fake_root);
        let path_val = env_file
            .iter()
            .find(|(k, _)| k == "PATH")
            .map(|(_, v)| v.as_str())
            .expect("PATH dumped");
        assert!(
            path_val.starts_with(&format!("{expected_bin}:")),
            "PATH did not prepend {expected_bin}: got {path_val}"
        );
    }
}
