//! Shared fixture and helpers for the CLI integration test binary.
//!
//! All tests in `tests/cli.rs` route through `fixture()`, a `LazyLock`
//! that spawns picloud on a private port the first time it's touched
//! and reuses that subprocess for every subsequent test. The dashboard
//! Playwright suite pays the same cost once for 63 tests; we do the
//! same here.

#![allow(dead_code)] // shared helpers — not every module uses every fn.

pub mod cleanup;
pub mod member;
pub mod server;

use std::path::PathBuf;
use std::process::Child;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{LazyLock, Mutex, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use assert_cmd::Command as AssertCommand;
use tempfile::TempDir;

// --------------------------------------------------------------------
// Fixture
// --------------------------------------------------------------------

pub struct Fixture {
    pub url: String,
    pub admin_token: String,
    pub admin_username: String,
    // Held in a Mutex so Drop can kill it without UB; we never re-enter.
    child: Mutex<Option<Child>>,
}

impl Drop for Fixture {
    fn drop(&mut self) {
        if let Ok(mut guard) = self.child.lock() {
            if let Some(mut child) = guard.take() {
                server::kill_subprocess(&mut child);
            }
        }
    }
}

static FIXTURE: LazyLock<Fixture> = LazyLock::new(init_fixture);

fn init_fixture() -> Fixture {
    let database_url =
        std::env::var("DATABASE_URL").expect("DATABASE_URL is required to spawn picloud");
    let username = admin_username();
    let password = admin_password();
    let port = server::pick_free_port();
    let url = format!("http://127.0.0.1:{port}");
    let mut child = server::spawn_picloud(&database_url, port, &username, &password);
    if let Err(e) = server::wait_for_health(&url, Duration::from_secs(60)) {
        server::kill_subprocess(&mut child);
        panic!("picloud failed to become healthy: {e}");
    }
    let token = server::login_for_bearer_token(&url, &username, &password);
    Fixture {
        url,
        admin_token: token,
        admin_username: username,
        child: Mutex::new(Some(child)),
    }
}

/// Returns the shared fixture, spawning the picloud subprocess on first
/// call. Returns `None` (and prints a skip message) when `DATABASE_URL`
/// is absent — matching the existing convention so the suite is a
/// no-op outside the integration environment.
pub fn fixture_or_skip() -> Option<&'static Fixture> {
    if std::env::var("DATABASE_URL").is_err() {
        eprintln!("skipping: DATABASE_URL not set");
        return None;
    }
    Some(&FIXTURE)
}

// --------------------------------------------------------------------
// Per-test env
// --------------------------------------------------------------------

pub struct TestEnv {
    pub url: String,
    pub token: String,
    pub config_dir: TempDir,
    pub home: TempDir,
}

/// Per-test env pre-loaded with the admin token, and a credentials
/// file already on disk so non-login commands ("pic apps create", …)
/// can run without first calling `pic login`. As of the env-var
/// consistency fix, `PICLOUD_URL`/`PICLOUD_TOKEN` (set by `pic_as`)
/// also work for *every* command, not just `login` — `config::resolve`
/// reads them first and falls back to the on-disk file.
pub fn admin_env(fx: &Fixture) -> TestEnv {
    let env = TestEnv {
        url: fx.url.clone(),
        token: fx.admin_token.clone(),
        config_dir: TempDir::new().expect("config tempdir"),
        home: TempDir::new().expect("home tempdir"),
    };
    seed_credentials(&env, &fx.admin_username);
    env
}

/// Per-test env pre-loaded with a specific (URL, token) pair. Used by
/// tests that want a non-admin token, a bogus token, or an unreachable
/// URL. Does **not** seed a credentials file — call `seed_credentials`
/// explicitly when the test needs to run non-login commands.
pub fn custom_env(url: &str, token: &str) -> TestEnv {
    TestEnv {
        url: url.to_string(),
        token: token.to_string(),
        config_dir: TempDir::new().expect("config tempdir"),
        home: TempDir::new().expect("home tempdir"),
    }
}

/// Write a valid credentials TOML into `env.config_dir` so subsequent
/// `pic_as(&env)` invocations can issue non-login subcommands. Mirrors
/// the file shape `pic login` produces (url/token/username). Tests that
/// exercise the "no credentials" / "stale token" error paths construct
/// `TestEnv` directly to keep the config dir empty.
pub fn seed_credentials(env: &TestEnv, username: &str) {
    let body = format!(
        "url = \"{}\"\ntoken = \"{}\"\nusername = \"{}\"\n",
        env.url, env.token, username,
    );
    std::fs::write(env.config_dir.path().join("credentials"), body).expect("seed credentials file");
}

/// `pic` invocation with the env wired up — credentials dir, HOME, and
/// the `PICLOUD_URL`/`PICLOUD_TOKEN` shortcut env vars.
pub fn pic_as(env: &TestEnv) -> AssertCommand {
    let mut cmd = AssertCommand::cargo_bin("pic").expect("pic binary");
    cmd.env("PICLOUD_URL", &env.url)
        .env("PICLOUD_TOKEN", &env.token)
        .env("PICLOUD_CONFIG_DIR", env.config_dir.path())
        .env("HOME", env.home.path());
    cmd
}

/// `pic` invocation with `PICLOUD_URL`/`PICLOUD_TOKEN` *cleared*, so the
/// command sees only the on-disk credentials file (or lack thereof).
pub fn pic_no_env(env: &TestEnv) -> AssertCommand {
    let mut cmd = AssertCommand::cargo_bin("pic").expect("pic binary");
    cmd.env_remove("PICLOUD_URL")
        .env_remove("PICLOUD_TOKEN")
        .env("PICLOUD_CONFIG_DIR", env.config_dir.path())
        .env("HOME", env.home.path());
    cmd
}

// --------------------------------------------------------------------
// Unique slugs / usernames
// --------------------------------------------------------------------

static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0);

pub fn unique_slug(prefix: &str) -> String {
    let ms = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_millis();
    let n = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
    format!("pic-cli-{prefix}-{ms}-{n:x}")
}

pub fn unique_username(prefix: &str) -> String {
    // Server regex: [a-z0-9._-]{2,32}. Build out of lowercase
    // alphanumerics only; "piccli" prefix keeps collisions with other
    // test suites obvious. Caller's `prefix` must be ≤8 chars and
    // already match the regex.
    let ms = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_millis();
    let n = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
    let name = format!("piccli{prefix}{ms:x}{n:x}");
    assert!(name.len() <= 32, "username overflow: {name}");
    name
}

// --------------------------------------------------------------------
// Misc helpers
// --------------------------------------------------------------------

pub fn admin_username() -> String {
    std::env::var("PICLOUD_CLI_E2E_USERNAME").unwrap_or_else(|_| "admin".to_string())
}

pub fn admin_password() -> String {
    std::env::var("PICLOUD_CLI_E2E_PASSWORD").unwrap_or_else(|_| "admin".to_string())
}

pub fn fixture_path(name: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fixtures")
        .join(name)
}

/// Create a fresh app and deploy a `tests/fixtures/<fixture_name>` into
/// it. Returns the new script id plus the `AppGuard` that cleans the
/// app (and its scripts via `force=true`) on Drop. Used by invoke /
/// logs / output journeys that all need "deploy something, then drive
/// `pic` against it".
pub fn deploy_fixture(
    env: &TestEnv,
    app_label: &str,
    fixture_name: &str,
) -> (String, cleanup::AppGuard) {
    let slug = unique_slug(app_label);
    pic_as(env)
        .args(["apps", "create", &slug])
        .assert()
        .success();
    let guard = cleanup::AppGuard::new(&env.url, &env.token, &slug);

    let fixture = fixture_path(fixture_name);
    pic_as(env)
        .args([
            "scripts",
            "deploy",
            fixture.to_str().unwrap(),
            "--app",
            &slug,
        ])
        .assert()
        .success();

    let out = pic_as(env)
        .args(["scripts", "ls", "--app", &slug])
        .output()
        .expect("scripts ls");
    let id = parse_first_id(std::str::from_utf8(&out.stdout).unwrap())
        .expect("scripts ls should produce one row");
    (id, guard)
}

/// Split a row from `pic apps ls` / `pic scripts ls` into trimmed
/// cells. The output writer space-pads each cell to its column's max
/// width before the tab, so raw `split('\t')` leaves trailing spaces;
/// this helper hides that detail from tests that only care about the
/// logical values.
pub fn cells(row: &str) -> Vec<&str> {
    row.split('\t').map(str::trim).collect()
}

/// First data row's first tab-delimited cell, used to extract IDs from
/// `pic scripts ls` output. The header is expected to start with "id".
pub fn parse_first_id(table: &str) -> Option<String> {
    let mut lines = table.lines().filter(|l| !l.trim().is_empty());
    let header = lines.next()?;
    if !header.starts_with("id") {
        return None;
    }
    let row = lines.next()?;
    let first = row.split('\t').next()?.trim();
    if first.is_empty() {
        None
    } else {
        Some(first.to_string())
    }
}

// --------------------------------------------------------------------
// Fixture-sharing sanity check
// --------------------------------------------------------------------
//
// Two tests record `fixture().url` into the same `OnceLock` — if the
// fixture isn't actually shared, the second test sees a different URL
// and panics. Belt-and-suspenders: pointer identity on `&Fixture`.

static OBSERVED_URL: OnceLock<String> = OnceLock::new();

fn observe_fixture_url(label: &str) {
    let Some(fx) = fixture_or_skip() else {
        return;
    };
    let url = fx.url.clone();
    match OBSERVED_URL.get() {
        Some(prev) => assert_eq!(
            prev, &url,
            "{label} observed a different fixture URL: prior={prev} now={url}"
        ),
        None => {
            let _ = OBSERVED_URL.set(url);
        }
    }
    // Same `&'static Fixture` from every call — proves the LazyLock is
    // sharing, not respawning.
    let a = fixture_or_skip().unwrap();
    let b = fixture_or_skip().unwrap();
    assert!(
        std::ptr::eq(a, b),
        "fixture_or_skip should return the same &'static Fixture"
    );
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn fixture_url_is_shared_a() {
    observe_fixture_url("fixture_url_is_shared_a");
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn fixture_url_is_shared_b() {
    observe_fixture_url("fixture_url_is_shared_b");
}
