ztnet 0.1.14

ZTNet CLI — manage ZeroTier networks via ZTNet
use std::future::Future;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};

use reqwest::StatusCode;
use url::Url;

use crate::error::CliError;
use crate::host::{api_base_candidates, normalize_host_input};

#[derive(Debug)]
pub(crate) struct BaseCandidate {
	pub display: String,
	pub url: Url,
}

pub(crate) fn build_base_candidates(base_url: &str) -> Result<Vec<BaseCandidate>, CliError> {
	let base_url = normalize_host_input(base_url)?;
	let candidates = api_base_candidates(&base_url);
	let mut bases = Vec::with_capacity(candidates.len());
	for candidate in candidates {
		let mut url = Url::parse(&candidate)?;
		normalize_base_url_for_join(&mut url);
		bases.push(BaseCandidate {
			display: candidate,
			url,
		});
	}

	Ok(bases)
}

pub(crate) fn normalize_base_url_for_join(url: &mut Url) {
	url.set_query(None);
	url.set_fragment(None);

	let path = url.path();
	if !path.ends_with('/') {
		let mut new_path = path.to_string();
		new_path.push('/');
		url.set_path(&new_path);
	}
}

pub(crate) fn join_relative_url(base_url: &Url, path: &str) -> Result<Url, CliError> {
	let relative = path.trim().trim_start_matches('/');
	Ok(base_url.join(relative)?)
}

pub(crate) fn normalize_and_join_url(base_url: &mut Url, path: &str) -> Result<Url, CliError> {
	normalize_base_url_for_join(base_url);
	join_relative_url(base_url, path)
}

pub(crate) fn parse_normalize_and_join_url(base: &str, path: &str) -> Result<Url, CliError> {
	let mut base_url = Url::parse(base)?;
	normalize_and_join_url(&mut base_url, path)
}

pub(crate) fn build_url_for_base(
	bases: &[BaseCandidate],
	base_idx: usize,
	path: &str,
	allow_absolute: bool,
) -> Result<Url, CliError> {
	let path = path.trim();
	if allow_absolute && (path.starts_with("http://") || path.starts_with("https://")) {
		return Ok(Url::parse(path)?);
	}

	let base = bases.get(base_idx).ok_or_else(|| {
		CliError::InvalidArgument("invalid internal host base index".to_string())
	})?;
	join_relative_url(&base.url, path)
}

pub(crate) fn maybe_warn_host_autofix<F>(
	quiet: bool,
	warned_autofix: &AtomicBool,
	bases: &[BaseCandidate],
	active_idx: usize,
	print_banner: F,
) where
	F: FnOnce(&str, &str),
{
	if quiet {
		return;
	}
	if active_idx == 0 {
		return;
	}
	if warned_autofix.swap(true, Ordering::Relaxed) {
		return;
	}

	let Some(configured) = bases.first().map(|b| b.display.as_str()) else {
		return;
	};
	let Some(using) = bases.get(active_idx).map(|b| b.display.as_str()) else {
		return;
	};

	print_banner(configured, using);
}

pub(crate) fn should_try_host_autofix_basic(err: &CliError) -> bool {
	match err {
		CliError::HttpStatus { status, .. } => {
			matches!(*status, StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED)
		}
		CliError::Request(err) => err.is_decode(),
		_ => false,
	}
}

pub(crate) async fn try_with_base_fallback<T, F, Fut, ShouldTry, OnSwitch>(
	bases: &[BaseCandidate],
	active_base: &AtomicUsize,
	path: &str,
	allow_absolute: bool,
	should_try_host_autofix: ShouldTry,
	mut attempt: F,
	mut on_switch: OnSwitch,
) -> Result<T, CliError>
where
	F: FnMut(Url) -> Fut,
	Fut: Future<Output = Result<T, CliError>>,
	ShouldTry: Fn(&CliError) -> bool,
	OnSwitch: FnMut(usize),
{
	let path = path.trim();
	let is_absolute = allow_absolute && (path.starts_with("http://") || path.starts_with("https://"));

	let base_idx = active_base.load(Ordering::Relaxed);
	let url = build_url_for_base(bases, base_idx, path, allow_absolute)?;
	let result = attempt(url).await;

	if is_absolute || bases.len() < 2 {
		return result;
	}

	match result {
		Ok(value) => Ok(value),
		Err(err) if should_try_host_autofix(&err) => {
			for idx in 0..bases.len() {
				if idx == base_idx {
					continue;
				}

				let url = build_url_for_base(bases, idx, path, allow_absolute)?;
				let alt_result = attempt(url).await;
				if let Ok(value) = alt_result {
					active_base.store(idx, Ordering::Relaxed);
					on_switch(idx);
					return Ok(value);
				}
			}

			Err(err)
		}
		Err(err) => Err(err),
	}
}