zc2 0.0.25

P2P compute broker with credit-based billing, WAL, and broker mesh support
//! `zc init` — device-code browser authentication.
//!
//! Flow:
//!   1. POST /api/auth/cli/start  → get device_code, user_code, verification_uri, interval
//!   2. Open the browser at verification_uri
//!   3. Poll POST /api/auth/cli/poll with {device_code} every `interval` seconds
//!   4. On 200 + api_key  → save credentials, exit 0
//!      On 428            → keep polling (pending)
//!      On anything else  → print error, exit 1

use std::time::{Duration, Instant};

/// Outcome of a single poll response.
pub enum Decision {
    Pending,
    Approved(String),
    Failed(String),
}

/// Pure decision function — maps an HTTP status + body to a `Decision`.
/// No network I/O; suitable for unit tests.
pub fn decide(http_status: u16, body: &str) -> Decision {
    match http_status {
        428 => Decision::Pending,
        200 => {
            match serde_json::from_str::<serde_json::Value>(body)
                .ok()
                .and_then(|v| v["api_key"].as_str().map(|s| s.to_string()))
            {
                Some(k) => Decision::Approved(k),
                None => Decision::Failed("approved but no api_key in response".into()),
            }
        }
        410 => Decision::Failed("code expired — run `zc init` again".into()),
        403 => Decision::Failed("authorization was denied".into()),
        404 => Decision::Failed("session not found".into()),
        s => Decision::Failed(format!("unexpected status {s}")),
    }
}

/// Print how to connect by setting an API key, listing the known dashboards.
/// Shown when browser device-code sign-in isn't available on the target.
pub fn print_connect_guidance() {
    eprintln!();
    eprintln!("To connect, grab your API key from the dashboard (Profile → API Keys), then:");
    eprintln!();
    eprintln!("  # pick your environment");
    eprintln!(
        "  export ZAKURO_API_URL={}   # production",
        crate::credentials::PROD_API_URL
    );
    eprintln!(
        "  export ZAKURO_API_URL={}   # staging",
        crate::credentials::STAGING_API_URL
    );
    eprintln!();
    eprintln!("  export ZAKURO_API_KEY=<your key>");
    eprintln!("  zc connect            # join the mesh (add --docker to use Docker)");
}

/// What `zc login` should do, given the credential state and flags. Split out
/// from `run` so the routing is unit-testable without a network or a browser.
#[derive(Debug, PartialEq)]
pub(crate) enum Plan {
    /// Signed in already: bring up the mesh instead of printing another command.
    ConnectMesh,
    /// Signed in already, but the caller asked us not to touch the network.
    ReportOnly,
    /// No usable key, or `--force`: run the device-code sign-in.
    SignIn,
}

pub(crate) fn plan(has_key: bool, force: bool, no_connect: bool) -> Plan {
    if !has_key || force {
        return Plan::SignIn;
    }
    if no_connect {
        return Plan::ReportOnly;
    }
    Plan::ConnectMesh
}

/// Bring up mesh access and report it. Shared by both sign-in paths so a fresh
/// sign-in and an already-signed-in re-run print the same thing.
///
/// Returns true when the mesh is reachable. Safe to call when already connected:
/// `vpn::connect` returns the live connection instead of rebuilding the tunnel.
fn connect_mesh() -> bool {
    match crate::vpn::ensure(crate::vpn::connector::Preference::Auto) {
        Ok(crate::vpn::MeshAccess::Host) => {
            println!("✓ Mesh reachable from this host. Try `zc me`.");
            true
        }
        Ok(crate::vpn::MeshAccess::Proxy(p)) => {
            println!("✓ Mesh reachable via VPN container proxy ({p}). Try `zc me`.");
            true
        }
        Err(e) => {
            eprintln!("✗ VPN setup failed: {e}");
            eprintln!("  Retry with `zc connect`.");
            false
        }
    }
}

/// Run the `zc init` device-code flow. Returns an exit code (0 = success).
pub fn run() -> i32 {
    crate::credentials::load_into_env();

    // `--staging` (or `ZAKURO_ENV=staging`) points init at the staging dashboard.
    // An explicit `ZAKURO_API_URL` still wins over both.
    let api_url =
        if std::env::args().any(|a| a == "--staging") && std::env::var("ZAKURO_API_URL").is_err() {
            crate::credentials::STAGING_API_URL.to_string()
        } else {
            crate::credentials::default_api_url()
        };

    let has_key = std::env::var("ZAKURO_API_KEY")
        .map(|k| !k.trim().is_empty())
        .unwrap_or(false);
    let force = std::env::args().any(|a| a == "--force");
    let no_connect = std::env::args().any(|a| a == "--no-connect");
    match plan(has_key, force, no_connect) {
        Plan::ReportOnly => {
            eprintln!("Already signed in (→ {api_url}).");
            eprintln!("  Connect with:   zc connect");
            eprintln!("  Re-auth with:   zc login --force");
            return 0;
        }
        Plan::ConnectMesh => {
            // Already authenticated: go straight to the mesh rather than printing
            // a second command to run. `zc login` then means "get this machine
            // ready" whatever state it starts in, matching a fresh sign-in.
            eprintln!("Already signed in (→ {api_url}).");
            eprintln!("  Connecting to the zakuro mesh…");
            // Exit 0 even when the mesh is unreachable: this command's contract
            // is "am I authenticated", it has always returned 0 here, and a
            // flaky network should not start failing scripts that re-run it.
            connect_mesh();
            return 0;
        }
        Plan::SignIn => {}
    }

    // ── start ──────────────────────────────────────────────────────────
    // Browser device-code sign-in. Not every dashboard exposes it yet; when the
    // endpoint is missing (404) or unreachable we fall back to explicit guidance
    // rather than dumping a raw error — the user can always set ZAKURO_API_KEY.
    let start_url = format!("{}/api/auth/cli/start", api_url.trim_end_matches('/'));
    let start_send = ureq::post(&start_url)
        .config()
        .http_status_as_error(false)
        .build()
        .send("");
    let start_resp = match start_send {
        Ok(r) if r.status().as_u16() == 200 => r,
        Ok(r) if r.status().as_u16() == 404 => {
            eprintln!(
                "Browser sign-in isn't available on {api_url} (device-code endpoint not found)."
            );
            print_connect_guidance();
            // With a working key already set, this isn't a real failure.
            return if has_key { 0 } else { 1 };
        }
        Ok(r) => {
            eprintln!(
                "Sign-in could not start on {api_url}: HTTP {}",
                r.status().as_u16()
            );
            print_connect_guidance();
            return 1;
        }
        Err(e) => {
            eprintln!("Could not reach {api_url}: {e}");
            print_connect_guidance();
            return 1;
        }
    };
    let start_body = match start_resp.into_body().read_to_string() {
        Ok(s) => s,
        Err(e) => {
            eprintln!("Failed to read start response: {e}");
            return 1;
        }
    };
    let start: serde_json::Value = match serde_json::from_str(&start_body) {
        Ok(v) => v,
        Err(e) => {
            eprintln!("Failed to parse start response: {e}");
            return 1;
        }
    };

    let device_code = start["device_code"].as_str().unwrap_or("").to_string();
    let user_code = start["user_code"].as_str().unwrap_or("");
    let verification_uri = start["verification_uri"].as_str().unwrap_or("");
    let interval = start["interval"].as_u64().unwrap_or(5);
    let expires_in = start["expires_in"].as_u64().unwrap_or(300);

    println!("\n  To sign in, open:      {verification_uri}");
    println!("  and confirm the code:  {user_code}\n");
    let _ = open_browser(verification_uri);

    // ── poll ───────────────────────────────────────────────────────────
    let poll_url = format!("{}/api/auth/cli/poll", api_url.trim_end_matches('/'));
    let poll_start = Instant::now();
    loop {
        std::thread::sleep(Duration::from_secs(interval));

        if poll_start.elapsed().as_secs() >= expires_in {
            eprintln!("\nCode expired — run `zc init` again");
            return 1;
        }

        let payload = serde_json::to_string(&serde_json::json!({"device_code": &device_code}))
            .unwrap_or_default();

        let (status, resp_body) = match ureq::post(&poll_url)
            .config()
            .http_status_as_error(false)
            .build()
            .header("Content-Type", "application/json")
            .send(payload.as_str())
        {
            Ok(r) => {
                let s = r.status().as_u16();
                let b = r.into_body().read_to_string().unwrap_or_default();
                (s, b)
            }
            Err(_) => continue, // transient network error — keep polling
        };

        match decide(status, &resp_body) {
            Decision::Pending => {
                print!(".");
                use std::io::Write;
                let _ = std::io::stdout().flush();
            }
            Decision::Approved(key) => {
                if let Err(e) = crate::credentials::save(&key, Some(&api_url)) {
                    eprintln!("\nsigned in but could not write credentials: {e}");
                    return 1;
                }
                // Export into THIS process so the mesh step below sees the key it
                // just minted — otherwise `vpn::ensure` (which reads the env) fails
                // with "p2p requires ZAKURO_API_KEY" immediately after a successful
                // sign-in.
                std::env::set_var("ZAKURO_API_KEY", &key);
                std::env::set_var("ZAKURO_API_URL", &api_url);
                println!("\n✓ Signed in. Key saved to ~/.zakuro/credentials.");
                println!("  Connecting to the zakuro mesh…");
                // Unlike the already-signed-in path, a fresh sign-in reports a
                // mesh failure as a failure: the user asked to get set up now and
                // did not, so exiting 0 would misreport a half-finished setup.
                return if connect_mesh() { 0 } else { 1 };
            }
            Decision::Failed(msg) => {
                eprintln!("\n{msg}");
                return 1;
            }
        }
    }
}

pub fn open_browser(url: &str) -> std::io::Result<()> {
    let cmd = if cfg!(target_os = "macos") {
        "open"
    } else {
        "xdg-open"
    };
    std::process::Command::new(cmd)
        .arg(url)
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .spawn()
        .map(|_| ())
}

#[cfg(test)]
mod tests {
    #[test]
    fn decision_maps_statuses() {
        assert!(matches!(super::decide(428, ""), super::Decision::Pending));
        assert!(matches!(
            super::decide(200, "{\"api_key\":\"zk_1_x\"}"),
            super::Decision::Approved(ref k) if k == "zk_1_x"
        ));
        assert!(matches!(super::decide(410, ""), super::Decision::Failed(_)));
    }

    // `zc login` used to stop at "Already signed in" and print `zc vpn connect`
    // for the user to run themselves, so getting a machine ready took two
    // commands on every run after the first. It now connects the mesh itself.
    #[test]
    fn already_signed_in_connects_the_mesh() {
        assert_eq!(super::plan(true, false, false), super::Plan::ConnectMesh);
    }

    #[test]
    fn no_key_signs_in() {
        assert_eq!(super::plan(false, false, false), super::Plan::SignIn);
    }

    // --force re-auths even with a key present, and outranks --no-connect.
    #[test]
    fn force_signs_in_again() {
        assert_eq!(super::plan(true, true, false), super::Plan::SignIn);
        assert_eq!(super::plan(true, true, true), super::Plan::SignIn);
    }

    // Escape hatch for callers relying on this path having no side effects.
    #[test]
    fn no_connect_keeps_the_old_report_only_behaviour() {
        assert_eq!(super::plan(true, false, true), super::Plan::ReportOnly);
    }

    // An empty/whitespace key is not a key: `run` treats it as absent, so a
    // blank ZAKURO_API_KEY must still route to sign-in rather than the mesh.
    #[test]
    fn blank_key_is_not_signed_in() {
        assert_eq!(super::plan(false, false, true), super::Plan::SignIn);
    }
}