//! Anchor relative paths of externally-imported content to their source dir.
//!
//! A service pulled in via `include:` or an external `extends: { file: ... }`
//! carries paths (build context, env_file, bind-mount sources, secret/config
//! files) that the compose spec resolves relative to the directory of the file
//! that defined them — not the top-level project directory. The engine resolves
//! every relative path against a single project base directory, so before such
//! content is merged we rewrite its relative paths to absolute paths anchored at
//! the imported file's directory. Absolute paths then pass through the engine's
//! base-directory resolution unchanged.

use std::path::Path;

use super::types::{
	BuildConfig, ComposeFile, EnvFile, EnvFileEntry, Service, VolumeMount, VolumeType,
};

/// Anchor every path-bearing field of an imported compose file to `dir`.
pub(super) fn anchor_compose_file(file: &mut ComposeFile, dir: &Path) {
	for svc in file.services.values_mut() {
		anchor_service(svc, dir);
	}
	for secret in file.secrets.values_mut() {
		if let Some(f) = secret.file.as_deref().and_then(|f| anchor_fs(f, dir)) {
			secret.file = Some(f);
		}
	}
	for config in file.configs.values_mut() {
		if let Some(f) = config.file.as_deref().and_then(|f| anchor_fs(f, dir)) {
			config.file = Some(f);
		}
	}
}

/// Anchor the path-bearing fields of a single service to `dir`.
pub(super) fn anchor_service(svc: &mut Service, dir: &Path) {
	if let Some(build) = svc.build.as_mut() {
		match build {
			BuildConfig::Context(c) => {
				if let Some(a) = anchor_context(c, dir) {
					*c = a;
				}
			}
			BuildConfig::Config { context, .. } => {
				// An absent `context` defaults to the project directory `.`;
				// anchor that default to the included file's directory so a
				// `dockerfile_inline`-only build resolves against the right dir.
				let effective = context.as_deref().unwrap_or(".");
				if let Some(a) = anchor_context(effective, dir) {
					*context = Some(a);
				}
			}
		}
	}

	match &mut svc.env_file {
		EnvFile::Empty => {}
		EnvFile::Single(e) => anchor_env_entry(e, dir),
		EnvFile::List(list) => {
			for e in list.iter_mut() {
				anchor_env_entry(e, dir);
			}
		}
	}

	for v in svc.volumes.iter_mut() {
		match v {
			VolumeMount::Short(s) => {
				if let Some(a) = anchor_short_volume(s, dir) {
					*s = a;
				}
			}
			VolumeMount::Long {
				volume_type: VolumeType::Bind,
				source: Some(src),
				..
			} => {
				if let Some(a) = anchor_bind(src, dir) {
					*src = a;
				}
			}
			VolumeMount::Long { .. } => {}
		}
	}
}

/// A path is anchorable when it is relative and not a `~` home reference
/// (home expansion happens later, against the user's home, not the file dir).
fn anchor_fs(path: &str, dir: &Path) -> Option<String> {
	if path.is_empty() || path.starts_with('~') || Path::new(path).is_absolute() {
		return None;
	}
	Some(dir.join(path).to_string_lossy().into_owned())
}

/// Build contexts may be Git/URL references, which must not be anchored.
fn anchor_context(context: &str, dir: &Path) -> Option<String> {
	if context.contains("://") || context.starts_with("git@") {
		return None;
	}
	anchor_fs(context, dir)
}

/// Only explicit relative bind sources (`./x`, `../x`) are anchored; named
/// volumes, absolute paths, and `~` references are left untouched.
fn anchor_bind(src: &str, dir: &Path) -> Option<String> {
	src.starts_with('.')
		.then(|| dir.join(src).to_string_lossy().into_owned())
}

fn anchor_short_volume(s: &str, dir: &Path) -> Option<String> {
	let parts: Vec<&str> = s.splitn(3, ':').collect();
	let src = parts.first()?;
	if !src.starts_with('.') {
		return None;
	}
	let mut rebuilt = dir.join(src).to_string_lossy().into_owned();
	for part in &parts[1..] {
		rebuilt.push(':');
		rebuilt.push_str(part);
	}
	Some(rebuilt)
}

fn anchor_env_entry(entry: &mut EnvFileEntry, dir: &Path) {
	match entry {
		EnvFileEntry::Path(p) => {
			if let Some(a) = anchor_fs(p, dir) {
				*p = a;
			}
		}
		EnvFileEntry::Config { path, .. } => {
			if let Some(a) = anchor_fs(path, dir) {
				*path = a;
			}
		}
	}
}

#[cfg(test)]
mod tests {
	use super::*;
	use crate::compose::types::Service;
	use std::path::Path;

	fn dir() -> &'static Path {
		Path::new("/proj/sub")
	}

	#[test]
	#[cfg(unix)]
	fn anchors_relative_build_context() {
		let mut svc = Service {
			build: Some(BuildConfig::Context("./app".into())),
			..Default::default()
		};
		anchor_service(&mut svc, dir());
		match svc.build.unwrap() {
			BuildConfig::Context(c) => assert_eq!(c, "/proj/sub/./app"),
			_ => panic!("expected context"),
		}
	}

	#[test]
	fn leaves_url_build_context() {
		let mut svc = Service {
			build: Some(BuildConfig::Context("https://github.com/x/y.git".into())),
			..Default::default()
		};
		anchor_service(&mut svc, dir());
		match svc.build.unwrap() {
			BuildConfig::Context(c) => assert_eq!(c, "https://github.com/x/y.git"),
			_ => panic!("expected context"),
		}
	}

	#[test]
	#[cfg(unix)]
	fn anchors_env_file_list() {
		let mut svc = Service {
			env_file: EnvFile::List(vec![
				EnvFileEntry::Path("./a.env".into()),
				EnvFileEntry::Path("/abs/b.env".into()),
			]),
			..Default::default()
		};
		anchor_service(&mut svc, dir());
		let EnvFile::List(list) = &svc.env_file else {
			panic!("expected list");
		};
		assert_eq!(list[0].path(), "/proj/sub/./a.env");
		assert_eq!(list[1].path(), "/abs/b.env");
	}

	#[test]
	#[cfg(unix)]
	fn anchors_relative_short_bind_only() {
		let mut svc = Service {
			volumes: vec![
				VolumeMount::Short("./data:/data:ro".into()),
				VolumeMount::Short("named:/v".into()),
				VolumeMount::Short("/abs:/a".into()),
			],
			..Default::default()
		};
		anchor_service(&mut svc, dir());
		let got: Vec<_> = svc
			.volumes
			.iter()
			.map(|v| match v {
				VolumeMount::Short(s) => s.clone(),
				_ => unreachable!(),
			})
			.collect();
		assert_eq!(got[0], "/proj/sub/./data:/data:ro");
		assert_eq!(got[1], "named:/v");
		assert_eq!(got[2], "/abs:/a");
	}

	#[test]
	fn leaves_tilde_paths() {
		let mut svc = Service {
			env_file: EnvFile::Single(EnvFileEntry::Path("~/x.env".into())),
			..Default::default()
		};
		anchor_service(&mut svc, dir());
		let EnvFile::Single(e) = &svc.env_file else {
			panic!("expected single");
		};
		assert_eq!(e.path(), "~/x.env");
	}

	#[test]
	#[cfg(unix)]
	fn anchors_long_form_build_context_default() {
		// A long-form build with no explicit `context:` defaults to `.`; that
		// default is anchored to the imported file's directory.
		let mut svc: Service =
			serde_yaml::from_str("build:\n  dockerfile_inline: |\n    FROM alpine\n").unwrap();
		anchor_service(&mut svc, dir());
		match svc.build.unwrap() {
			BuildConfig::Config { context, .. } => {
				assert_eq!(context.as_deref(), Some("/proj/sub/."));
			}
			_ => panic!("expected long-form config"),
		}
	}

	#[test]
	#[cfg(unix)]
	fn anchors_long_form_relative_bind_source() {
		// A long-form bind mount with a relative `./` source is anchored; a named
		// volume is left untouched.
		let mut svc: Service = serde_yaml::from_str(
			"volumes:\n  - type: bind\n    source: ./cfg\n    target: /cfg\n  \
			 - type: volume\n    source: named\n    target: /v\n",
		)
		.unwrap();
		anchor_service(&mut svc, dir());
		let VolumeMount::Long { source, .. } = &svc.volumes[0] else {
			panic!("expected long bind");
		};
		assert_eq!(source.as_deref(), Some("/proj/sub/./cfg"));
		// The named volume keeps its bare name (not a bind, not anchored).
		let VolumeMount::Long { source, .. } = &svc.volumes[1] else {
			panic!("expected long volume");
		};
		assert_eq!(source.as_deref(), Some("named"));
	}

	#[test]
	#[cfg(unix)]
	fn anchors_config_form_env_entry() {
		// The long-form (`{ path, required }`) env_file entry has its path anchored.
		let mut svc = Service {
			env_file: EnvFile::Single(EnvFileEntry::Config {
				path: "./svc.env".into(),
				required: Some(false),
				format: None,
			}),
			..Default::default()
		};
		anchor_service(&mut svc, dir());
		let EnvFile::Single(e) = &svc.env_file else {
			panic!("expected single");
		};
		assert_eq!(e.path(), "/proj/sub/./svc.env");
	}

	#[test]
	#[cfg(unix)]
	fn anchors_secret_and_config_files() {
		use crate::compose::types::{ConfigConfig, SecretConfig};
		let mut file = ComposeFile::default();
		file.secrets.insert(
			"tok".into(),
			SecretConfig {
				file: Some("./secret.txt".into()),
				..Default::default()
			},
		);
		// An absolute secret file is left as-is.
		file.secrets.insert(
			"abs".into(),
			SecretConfig {
				file: Some("/etc/abs.txt".into()),
				..Default::default()
			},
		);
		file.configs.insert(
			"cfg".into(),
			ConfigConfig {
				file: Some("./conf.yml".into()),
				..Default::default()
			},
		);
		anchor_compose_file(&mut file, dir());
		assert_eq!(
			file.secrets["tok"].file.as_deref(),
			Some("/proj/sub/./secret.txt")
		);
		assert_eq!(file.secrets["abs"].file.as_deref(), Some("/etc/abs.txt"));
		assert_eq!(
			file.configs["cfg"].file.as_deref(),
			Some("/proj/sub/./conf.yml")
		);
	}
}
