//! Shared helpers for collecting Tamanu server identity and host facts.
//!
//! Used by `canopy register`, `alertd`, and `doctor`. Kept here so each
//! subcommand pulls from one place rather than reaching into a sibling's module.

use std::path::{Path, PathBuf};

use miette::{IntoDiagnostic, Result};
use tracing::{debug, info, warn};
use uuid::Uuid;

/// Standard on-disk location for the Tamanu device key PEM.
///
/// - Linux: `/etc/tamanu/device-key.pem`
/// - Windows: `C:\Tamanu\device-key.pem`
/// - Other platforms: same as Linux.
///
/// This is the source of truth for the device key. The Tamanu DB's
/// `local_system_facts.deviceKey` row is a legacy fallback retained so older
/// installs can be migrated forward; new keys are generated by
/// [`get_or_create_device_key`] and written here.
pub fn standard_device_key_path() -> PathBuf {
	if cfg!(windows) {
		PathBuf::from(r"C:\Tamanu\device-key.pem")
	} else {
		PathBuf::from("/etc/tamanu/device-key.pem")
	}
}

/// Standard on-disk location for the Tamanu meta-server ID.
///
/// - Linux: `/etc/tamanu/server-id`
/// - Windows: `C:\Tamanu\server-id`
/// - Other platforms: same as Linux.
///
/// Like the device key, this is the preferred source; the
/// `local_system_facts.metaServerId` row is the legacy fallback.
pub fn standard_server_id_path() -> PathBuf {
	if cfg!(windows) {
		PathBuf::from(r"C:\Tamanu\server-id")
	} else {
		PathBuf::from("/etc/tamanu/server-id")
	}
}

/// Standard on-disk location for cached canopy tags.
///
/// Sits alongside `server-id` and `device-key.pem` so all per-host canopy
/// identity / metadata files share one directory. Refreshed every time
/// `bestool tamanu tags` runs successfully online; consulted (and trusted as
/// the source of truth) when canopy is unreachable.
pub fn standard_tags_path() -> PathBuf {
	if cfg!(windows) {
		PathBuf::from(r"C:\Tamanu\tags.json")
	} else {
		PathBuf::from("/etc/tamanu/tags.json")
	}
}

/// Resolve the `metaServerId` for this Tamanu server.
///
/// Resolution order:
/// 1. Read [`standard_server_id_path`] if present. This is the only step that
///    works without a DB connection; on already-provisioned hosts it always
///    succeeds, so callers like the doctor daemon can report status to canopy
///    even when postgres is down (which is precisely when canopy most needs
///    to hear from us).
/// 2. If `client` is `Some`, read the legacy `local_system_facts.metaServerId`
///    row; best-effort copy to the file path so subsequent runs don't need
///    the DB.
/// 3. If `client` is `Some`, generate a fresh UUIDv4. Persist to the file
///    path; if the file write fails, fall back to inserting into
///    `local_system_facts` so the new ID isn't lost across runs.
///
/// Returns an error when `client` is `None` *and* the file isn't present —
/// brand-new hosts have to reach the DB at least once to mint an ID.
pub async fn get_or_create_server_id(client: Option<&tokio_postgres::Client>) -> Result<String> {
	get_or_create_server_id_at(&standard_server_id_path(), client).await
}

/// Test-shimmed core of [`get_or_create_server_id`] — same contract, with
/// the file path injected so unit tests can drive it without touching
/// `/etc/tamanu`.
async fn get_or_create_server_id_at(
	path: &Path,
	client: Option<&tokio_postgres::Client>,
) -> Result<String> {
	#[cfg(feature = "canopy-registration")]
	if let Some(reg) = load_registration().await
		&& let Some(id) = reg.server_id
	{
		return Ok(id);
	}

	if let Some(id) = read_server_id_file(path) {
		return Ok(id);
	}

	let Some(client) = client else {
		miette::bail!(
			"no server-id file at {} and no DB connection to mint one from",
			path.display()
		);
	};

	if let Some(id) = query_server_id_row(client).await? {
		match write_server_id_file(path, &id) {
			Ok(()) => info!(
				path = %path.display(),
				"copied metaServerId from Tamanu DB to standard path"
			),
			Err(err) => debug!(
				path = %path.display(),
				%err,
				"could not copy metaServerId to standard path; will retry next run"
			),
		}
		return Ok(id);
	}

	let id = Uuid::new_v4().to_string();
	info!(server_id = %id, "generating new metaServerId");

	match write_server_id_file(path, &id) {
		Ok(()) => Ok(id),
		Err(err) => {
			debug!(
				path = %path.display(),
				%err,
				"could not persist new metaServerId to file; falling back to DB"
			);
			client
				.execute(
					"INSERT INTO local_system_facts (key, value) VALUES ('metaServerId', $1)",
					&[&id],
				)
				.await
				.into_diagnostic()?;
			Ok(id)
		}
	}
}

/// Read just the `metaServerId` row from the Tamanu DB, without creating one.
async fn query_server_id_row(client: &tokio_postgres::Client) -> Result<Option<String>> {
	let row = client
		.query_opt(
			"SELECT value FROM local_system_facts WHERE key = 'metaServerId'",
			&[],
		)
		.await
		.into_diagnostic()?;

	let Some(row) = row else {
		return Ok(None);
	};
	let id: String = row.try_get(0).into_diagnostic()?;
	debug!(server_id = %id, "found existing metaServerId in DB");
	Ok(Some(id))
}

fn read_server_id_file(path: &Path) -> Option<String> {
	match std::fs::read_to_string(path) {
		Ok(s) => {
			let trimmed = s.trim();
			if trimmed.is_empty() {
				warn!(path = %path.display(), "server-id file is empty; ignoring");
				return None;
			}
			if Uuid::parse_str(trimmed).is_err() {
				warn!(
					path = %path.display(),
					"server-id file does not contain a UUID; ignoring",
				);
				return None;
			}
			debug!(path = %path.display(), server_id = trimmed, "loaded metaServerId from standard path");
			Some(trimmed.to_string())
		}
		Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
		Err(err) => {
			debug!(path = %path.display(), %err, "could not read server-id file");
			None
		}
	}
}

fn write_server_id_file(path: &Path, id: &str) -> std::io::Result<()> {
	use std::io::Write as _;

	if let Some(parent) = path.parent()
		&& !parent.exists()
	{
		return Err(std::io::Error::new(
			std::io::ErrorKind::NotFound,
			"parent directory does not exist",
		));
	}

	let tmp = path.with_extension("tmp");
	{
		let mut f = std::fs::OpenOptions::new()
			.write(true)
			.create_new(true)
			.open(&tmp)?;
		#[cfg(unix)]
		{
			use std::os::unix::fs::PermissionsExt as _;
			let perms = std::fs::Permissions::from_mode(0o644);
			f.set_permissions(perms)?;
		}
		f.write_all(id.as_bytes())?;
		f.write_all(b"\n")?;
		f.sync_all()?;
	}
	std::fs::rename(&tmp, path)
}

/// Generate a fresh P-256 device key as a PKCS#8 PEM.
///
/// `canopy register` calls this to mint the machine's mTLS identity when it
/// has none yet; the key is then kept inside the encrypted canopy registration
/// rather than written to a standalone file. Safe in the operator-first flow:
/// the operator creates the server record in canopy first, and register
/// publishes the public key over mTLS, so a freshly minted key is bound rather
/// than stranded.
#[cfg(feature = "device-key")]
pub fn generate_device_key_pem() -> Result<String> {
	use miette::miette;
	use p256::{
		SecretKey,
		elliptic_curve::rand_core::OsRng,
		pkcs8::{EncodePrivateKey as _, LineEnding},
	};

	let key = SecretKey::random(&mut OsRng);
	let pem = key
		.to_pkcs8_pem(LineEnding::LF)
		.map_err(|e| miette!("failed to encode generated deviceKey: {e}"))?;
	Ok(pem.to_string())
}

/// Best-effort load of the Tamanu deviceKey PEM.
///
/// Tries the standard file path first ([`standard_device_key_path`]). If
/// that isn't readable, falls back to the legacy
/// `local_system_facts.deviceKey` row via the caller-supplied closure (which
/// receives the chance to use a pre-existing DB client). When the fallback
/// succeeds and the standard path doesn't already exist, attempts a
/// best-effort copy so subsequent runs don't need the DB.
///
/// Returns `None` if neither source yields a key. Logging is the only
/// signal: callers without a device key continue to work (canopy tailscale
/// path is still available).
/// Load this host's canopy registration, logging (and swallowing) errors.
///
/// The registration is the source of truth for the device key and server id;
/// callers fall back to the legacy plaintext paths / DB when it's absent.
#[cfg(feature = "canopy-registration")]
async fn load_registration() -> Option<bestool_canopy::registration::Registration> {
	match bestool_canopy::registration::load().await {
		Ok(opt) => opt,
		Err(err) => {
			warn!(%err, "could not load canopy registration; falling back to legacy paths");
			None
		}
	}
}

/// Best-effort device key from the canopy registration or the standard file
/// path (no DB). Used by callers that degrade cleanly when it's absent.
pub async fn fetch_device_key() -> Option<String> {
	#[cfg(feature = "canopy-registration")]
	if let Some(reg) = load_registration().await
		&& let Some(key) = reg.device_key
	{
		return Some(key);
	}

	read_device_key_file(&standard_device_key_path())
}

pub async fn fetch_device_key_with<F, Fut>(db_fetch: F) -> Option<String>
where
	F: FnOnce() -> Fut,
	Fut: std::future::Future<Output = Option<String>>,
{
	#[cfg(feature = "canopy-registration")]
	if let Some(reg) = load_registration().await
		&& let Some(key) = reg.device_key
	{
		return Some(key);
	}

	let path = standard_device_key_path();

	if let Some(pem) = read_device_key_file(&path) {
		return Some(pem);
	}

	let pem = db_fetch().await?;

	if !path.exists() {
		match write_device_key_file(&path, &pem) {
			Ok(()) => info!(
				path = %path.display(),
				"copied deviceKey from Tamanu DB to standard path"
			),
			Err(err) => debug!(
				path = %path.display(),
				%err,
				"could not copy deviceKey to standard path; will retry next run"
			),
		}
	}

	Some(pem)
}

/// Query the central server's `settings` table for `features.patientPortal`.
///
/// Tamanu mounts the patient portal API conditionally on this flag (see
/// `packages/central-server/app/createApi.js`), so a `true` value here is the
/// authoritative "this deployment runs the portal" signal — independent of
/// the ansible-side `PatientPortalFQDN` tag or the unused `patientPortal.portalUrl`
/// config field.
///
/// Settings are stored as one row per dotted leaf path, with the value column
/// as `JSONB`. The default if the row is absent is `false` (per the global
/// schema in `packages/settings/src/schema/global.ts`), so missing rows
/// resolve to `Some(false)`.
///
/// Returns `None` only when the query itself failed — that's the
/// "DB unreachable" / "transient SQL error" case, kept distinct from
/// `Some(false)` so callers can emit an Unknown expectation rather than
/// silently treating outage as opt-out.
pub async fn query_patient_portal_enabled(client: &tokio_postgres::Client) -> Option<bool> {
	match client
		.query_opt(
			"SELECT value FROM settings WHERE key = 'features.patientPortal' LIMIT 1",
			&[],
		)
		.await
	{
		Ok(Some(row)) => Some(
			row.try_get::<_, serde_json::Value>(0)
				.ok()
				.and_then(|v| v.as_bool())
				.unwrap_or(false),
		),
		Ok(None) => Some(false),
		Err(err) => {
			debug!(%err, "could not query features.patientPortal setting");
			None
		}
	}
}

/// Query `local_system_facts.deviceKey` on an existing Tamanu DB client.
///
/// Logs at warn/info on errors and missing rows, returning `None`. Suitable
/// to pass as the closure to [`fetch_device_key_with`].
pub async fn query_device_key_row(client: &tokio_postgres::Client) -> Option<String> {
	match client
		.query_opt(
			"SELECT value FROM local_system_facts WHERE key = 'deviceKey'",
			&[],
		)
		.await
	{
		Ok(Some(row)) => match row.try_get::<_, String>(0) {
			Ok(pem) => {
				info!("loaded deviceKey from Tamanu DB for canopy targets");
				Some(pem)
			}
			Err(err) => {
				warn!("deviceKey row not a string: {err}");
				None
			}
		},
		Ok(None) => {
			info!("no deviceKey in local_system_facts; canopy targets unavailable");
			None
		}
		Err(err) => {
			warn!("failed to query deviceKey: {err}");
			None
		}
	}
}

fn read_device_key_file(path: &Path) -> Option<String> {
	match std::fs::read_to_string(path) {
		Ok(s) if !s.trim().is_empty() => {
			debug!(path = %path.display(), "loaded deviceKey from standard path");
			Some(s)
		}
		Ok(_) => {
			warn!(path = %path.display(), "deviceKey file is empty; ignoring");
			None
		}
		Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
		Err(err) => {
			debug!(path = %path.display(), %err, "could not read deviceKey file");
			None
		}
	}
}

fn write_device_key_file(path: &Path, pem: &str) -> std::io::Result<()> {
	use std::io::Write as _;

	if let Some(parent) = path.parent()
		&& !parent.exists()
	{
		return Err(std::io::Error::new(
			std::io::ErrorKind::NotFound,
			"parent directory does not exist",
		));
	}

	// Write to a sibling temp file then rename, so a partial write never
	// leaves a half-readable PEM at the target path.
	let tmp = path.with_extension("pem.tmp");
	{
		let mut opts = std::fs::OpenOptions::new();
		opts.write(true).create_new(true);
		#[cfg(windows)]
		{
			use std::os::windows::fs::OpenOptionsExt as _;
			// FILE_ATTRIBUTE_HIDDEN — tucks the key out of casual Explorer view.
			// The attribute is set on creation so it applies before any bytes
			// land, and survives the rename below.
			const FILE_ATTRIBUTE_HIDDEN: u32 = 0x0000_0002;
			opts.attributes(FILE_ATTRIBUTE_HIDDEN);
		}
		let mut f = opts.open(&tmp)?;
		#[cfg(unix)]
		{
			use std::os::unix::fs::PermissionsExt as _;
			let perms = std::fs::Permissions::from_mode(0o600);
			f.set_permissions(perms)?;
		}
		f.write_all(pem.as_bytes())?;
		f.sync_all()?;
	}
	std::fs::rename(&tmp, path)
}

/// Read the tailscale Self node's first IP and DNS name, if tailscale is
/// installed and responding to `status --json`.
pub async fn get_tailscale_info() -> (Option<String>, Option<String>) {
	let output = match tokio::process::Command::new("tailscale")
		.arg("status")
		.arg("--json")
		.output()
		.await
	{
		Ok(output) if output.status.success() => output,
		Ok(output) => {
			debug!(
				status = %output.status,
				"tailscale status command failed"
			);
			return (None, None);
		}
		Err(e) => {
			debug!(error = %e, "tailscale not available");
			return (None, None);
		}
	};

	let parsed: serde_json::Value = match serde_json::from_slice(&output.stdout) {
		Ok(v) => v,
		Err(e) => {
			warn!(error = %e, "failed to parse tailscale status JSON");
			return (None, None);
		}
	};

	let self_node = &parsed["Self"];
	let ip = self_node["TailscaleIPs"]
		.as_array()
		.and_then(|ips| ips.first())
		.and_then(|ip| ip.as_str())
		.map(String::from);
	let name = self_node["DNSName"]
		.as_str()
		.map(|s| s.trim_end_matches('.').to_string());

	(ip, name)
}

/// Detect the host's installed Node.js version by running `node --version`.
///
/// Returns `None` if node isn't on `PATH` or the command fails. The leading
/// `v` that node prints (e.g. `v20.11.0`) is stripped, so the value is a bare
/// version string.
pub async fn detect_node_version() -> Option<String> {
	let output = tokio::process::Command::new("node")
		.arg("--version")
		.output()
		.await
		.ok()?;
	if !output.status.success() {
		debug!(status = %output.status, "node --version failed");
		return None;
	}

	parse_node_version(&String::from_utf8_lossy(&output.stdout))
}

/// Parse the output of `node --version` (e.g. `v20.11.0\n`) into a bare version
/// string. Returns `None` for empty/whitespace-only input.
fn parse_node_version(raw: &str) -> Option<String> {
	let version = raw.trim().trim_start_matches('v');
	if version.is_empty() {
		None
	} else {
		Some(version.to_string())
	}
}

/// Linux: read `systemd-detect-virt`'s output. Returns `None` if the command
/// is unavailable. The string is whatever systemd reports (e.g. `kvm`, `lxc`,
/// `none` for bare metal).
pub async fn detect_virtualisation() -> Option<String> {
	let output = tokio::process::Command::new("systemd-detect-virt")
		.output()
		.await
		.ok()?;

	let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
	if stdout.is_empty() {
		return None;
	}

	Some(stdout)
}

#[cfg(test)]
mod tests {
	use tokio::sync::Mutex;
	use tokio_postgres::NoTls;

	use super::*;

	static DB_TEST_MUTEX: Mutex<()> = Mutex::const_new(());

	async fn test_db_client() -> tokio_postgres::Client {
		let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for tests");
		let (client, connection) = tokio_postgres::connect(&url, NoTls).await.unwrap();
		tokio::spawn(async move {
			if let Err(e) = connection.await {
				eprintln!("connection error: {e}");
			}
		});

		client
			.batch_execute(
				"CREATE TABLE IF NOT EXISTS local_system_facts (
					key TEXT PRIMARY KEY,
					value TEXT NOT NULL
				)",
			)
			.await
			.unwrap();
		client
			.execute(
				"DELETE FROM local_system_facts WHERE key IN ('metaServerId', 'deviceKey')",
				&[],
			)
			.await
			.unwrap();

		client
	}

	#[tokio::test]
	async fn test_get_or_create_server_id_generates_new() {
		let _lock = DB_TEST_MUTEX.lock().await;
		let client = test_db_client().await;

		let id = get_or_create_server_id(Some(&client)).await.unwrap();
		assert!(!id.is_empty());
		uuid::Uuid::parse_str(&id).expect("should be a valid UUID");

		let row = client
			.query_one(
				"SELECT value FROM local_system_facts WHERE key = 'metaServerId'",
				&[],
			)
			.await
			.unwrap();
		let stored: String = row.get(0);
		assert_eq!(stored, id);
	}

	#[tokio::test]
	async fn test_get_or_create_server_id_returns_existing() {
		let _lock = DB_TEST_MUTEX.lock().await;
		let client = test_db_client().await;

		client
			.execute(
				"INSERT INTO local_system_facts (key, value) VALUES ('metaServerId', 'existing-id-123')",
				&[],
			)
			.await
			.unwrap();

		let id = get_or_create_server_id(Some(&client)).await.unwrap();
		assert_eq!(id, "existing-id-123");
	}

	#[tokio::test]
	async fn test_get_or_create_server_id_is_stable() {
		let _lock = DB_TEST_MUTEX.lock().await;
		let client = test_db_client().await;

		let id1 = get_or_create_server_id(Some(&client)).await.unwrap();
		let id2 = get_or_create_server_id(Some(&client)).await.unwrap();
		assert_eq!(id1, id2);
	}

	#[tokio::test]
	async fn server_id_resolves_from_file_without_db() {
		// The boot-time canopy push case: postgres isn't reachable yet, but
		// the host's been provisioned so the standard file path has an id.
		// Resolution must succeed without consulting the DB at all.
		let dir = tempfile::tempdir().unwrap();
		let path = dir.path().join("server-id");
		let cached = uuid::Uuid::new_v4().to_string();
		std::fs::write(&path, &cached).unwrap();

		let id = get_or_create_server_id_at(&path, None).await.unwrap();
		assert_eq!(id, cached);
	}

	#[tokio::test]
	async fn server_id_errors_without_db_and_without_file() {
		// Brand-new host with no DB connection: there's no id to find, and
		// no way to mint one — must surface as an error so the caller knows
		// the cause.
		let dir = tempfile::tempdir().unwrap();
		let path = dir.path().join("server-id");
		let err = get_or_create_server_id_at(&path, None)
			.await
			.expect_err("no file, no db → must error");
		let msg = format!("{err}");
		assert!(msg.contains("server-id"), "{msg}");
	}

	#[test]
	fn parse_node_version_strips_v_prefix_and_whitespace() {
		assert_eq!(parse_node_version("v20.11.0\n").as_deref(), Some("20.11.0"));
		assert_eq!(
			parse_node_version("  v18.19.1  ").as_deref(),
			Some("18.19.1")
		);
		assert_eq!(parse_node_version("20.11.0").as_deref(), Some("20.11.0"));
	}

	#[test]
	fn parse_node_version_treats_empty_as_none() {
		assert!(parse_node_version("").is_none());
		assert!(parse_node_version("  \n").is_none());
	}

	#[test]
	fn read_device_key_file_returns_none_for_missing() {
		let dir = tempfile::tempdir().unwrap();
		let path = dir.path().join("missing.pem");
		assert!(read_device_key_file(&path).is_none());
	}

	#[test]
	fn read_device_key_file_returns_contents() {
		let dir = tempfile::tempdir().unwrap();
		let path = dir.path().join("device.pem");
		std::fs::write(&path, "PEM CONTENT").unwrap();
		assert_eq!(read_device_key_file(&path).as_deref(), Some("PEM CONTENT"));
	}

	#[test]
	fn read_device_key_file_treats_empty_as_missing() {
		let dir = tempfile::tempdir().unwrap();
		let path = dir.path().join("empty.pem");
		std::fs::write(&path, "   \n").unwrap();
		assert!(read_device_key_file(&path).is_none());
	}

	#[test]
	fn write_device_key_file_creates_and_roundtrips() {
		let dir = tempfile::tempdir().unwrap();
		let path = dir.path().join("device.pem");
		write_device_key_file(&path, "PEM").unwrap();
		assert_eq!(std::fs::read_to_string(&path).unwrap(), "PEM");

		#[cfg(unix)]
		{
			use std::os::unix::fs::PermissionsExt as _;
			let mode = std::fs::metadata(&path).unwrap().permissions().mode();
			assert_eq!(mode & 0o777, 0o600);
		}
	}

	#[test]
	fn write_device_key_file_errors_when_parent_missing() {
		let dir = tempfile::tempdir().unwrap();
		let path = dir.path().join("nope").join("device.pem");
		assert!(write_device_key_file(&path, "PEM").is_err());
	}

	#[test]
	fn write_device_key_file_refuses_to_overwrite() {
		let dir = tempfile::tempdir().unwrap();
		let path = dir.path().join("device.pem");
		std::fs::write(&path, "OLD").unwrap();
		// The temp file create_new path collides if a previous attempt left it
		// behind; the rename then overwrites. Easier check: the function
		// succeeds-and-replaces here, but the caller never invokes it when
		// `path.exists()` (see fetch_device_key). Just verify the rename
		// behaviour didn't lose data.
		write_device_key_file(&path, "NEW").unwrap();
		assert_eq!(std::fs::read_to_string(&path).unwrap(), "NEW");
	}

	#[test]
	fn read_server_id_file_returns_none_for_missing() {
		let dir = tempfile::tempdir().unwrap();
		assert!(read_server_id_file(&dir.path().join("missing")).is_none());
	}

	#[test]
	fn read_server_id_file_returns_uuid() {
		let dir = tempfile::tempdir().unwrap();
		let path = dir.path().join("server-id");
		let id = "7deb2793-0425-427e-8a19-7213946fa9be";
		std::fs::write(&path, format!("{id}\n")).unwrap();
		assert_eq!(read_server_id_file(&path).as_deref(), Some(id));
	}

	#[test]
	fn read_server_id_file_rejects_non_uuid() {
		let dir = tempfile::tempdir().unwrap();
		let path = dir.path().join("server-id");
		std::fs::write(&path, "not-a-uuid\n").unwrap();
		assert!(read_server_id_file(&path).is_none());
	}

	#[test]
	fn write_server_id_file_roundtrips() {
		let dir = tempfile::tempdir().unwrap();
		let path = dir.path().join("server-id");
		let id = Uuid::new_v4().to_string();
		write_server_id_file(&path, &id).unwrap();
		assert_eq!(read_server_id_file(&path).as_deref(), Some(id.as_str()));

		#[cfg(unix)]
		{
			use std::os::unix::fs::PermissionsExt as _;
			let mode = std::fs::metadata(&path).unwrap().permissions().mode();
			assert_eq!(mode & 0o777, 0o644);
		}
	}

	#[cfg(feature = "device-key")]
	#[test]
	fn generate_device_key_pem_produces_valid_pkcs8() {
		use p256::{SecretKey, pkcs8::DecodePrivateKey as _};

		let pem = generate_device_key_pem().unwrap();
		assert!(pem.starts_with("-----BEGIN PRIVATE KEY-----"));
		assert!(pem.trim_end().ends_with("-----END PRIVATE KEY-----"));
		SecretKey::from_pkcs8_pem(&pem).expect("generated PEM must parse as P-256 PKCS8");
	}

	#[cfg(feature = "device-key")]
	#[test]
	fn generate_device_key_pem_is_non_deterministic() {
		let a = generate_device_key_pem().unwrap();
		let b = generate_device_key_pem().unwrap();
		assert_ne!(a, b);
	}

	#[cfg(feature = "device-key")]
	#[test]
	fn generate_device_key_pem_parses_as_p256() {
		use p256::{SecretKey, pkcs8::DecodePrivateKey as _};

		let pem = generate_device_key_pem().unwrap();
		SecretKey::from_pkcs8_pem(&pem).expect("returned PEM must parse as P-256 PKCS8");
	}
}
