//! `bestool canopy tags` — fetch this device's tags from canopy.
//!
//! Calls the canopy `get_self` endpoint over either the tailscale or mTLS
//! auth path (whichever `CanopyClient` picks), then caches the result next
//! to `server-id` so the tags remain available when canopy is unreachable.
//!
//! Cache is treated as a soft fallback, not a source of truth: a successful
//! online fetch always overwrites it; an offline run consults whatever was
//! last cached and warns that the data may be stale.

use std::{collections::BTreeMap, path::Path, sync::Arc};

use bestool_canopy::{CanopyClient, registration};
use clap::Parser;
use comfy_table::{Row, Table, presets::NOTHING};
use miette::{IntoDiagnostic, Result, WrapErr, bail, miette};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};

use bestool_tamanu::{config::load_config, server_info::standard_tags_path};

use crate::{actions::{Context, tamanu::TamanuArgs}, args::Args};

/// Fetch this device's tags from canopy.
///
/// Tags are key→value labels stored server-side in canopy, identifying what
/// role / fleet / labels this device carries; the server's own tags are
/// merged over its group's. The fetch is authenticated by the canopy
/// client (tailscale identity, or mTLS with the device key).
///
/// On a successful fetch the result is cached to disk alongside the
/// `server-id` file; on a failed fetch (canopy unreachable, no auth path,
/// HTTP error) the cached copy — if any — is read and printed instead, with
/// a `cached` flag set in the JSON output and a one-line note in the
/// human-readable output.
#[derive(Debug, Clone, Parser)]
#[clap(verbatim_doc_comment)]
pub struct TagsArgs {
	/// Emit the tags as JSON rather than a human-readable table.
	#[arg(long)]
	pub json: bool,

	/// Skip the network fetch and print whatever's in the cache, without
	/// trying canopy first. Useful for fully-offline diagnostic runs.
	#[arg(long)]
	pub offline: bool,
}

/// On-disk shape for the tags cache.
///
/// Wraps the tag map in a struct so we can carry a `fetched_at` timestamp
/// for "how stale is this cache" reporting. Forward-compatible against
/// future fields by being a named-fields struct rather than just the map.
///
/// Caches written before tags became a key→value map (when they were a bare
/// list) fail to parse and are ignored by [`load_cache`], same as any other
/// unparseable cache.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TagsCache {
	tags: BTreeMap<String, String>,
	#[serde(default)]
	fetched_at: Option<jiff::Timestamp>,
}

pub async fn run(args: TagsArgs, ctx: Context) -> Result<()> {
	// `--root` only exists on the `tamanu` alias path; under `canopy` there's
	// no `TamanuArgs` in the context, so fall back to default discovery.
	let root_arg = ctx.get::<TamanuArgs>().and_then(|t| t.root.clone());
	let (_version, root) = bestool_tamanu::find_tamanu(root_arg.as_deref()).await?;
	let config = Arc::new(load_config(&root, None)?);

	// The cache lives alongside the registration; the legacy `C:\Tamanu` /
	// `/etc/tamanu` location is still read so a host that hasn't refreshed since
	// the move keeps its cached tags until the next online fetch rewrites them.
	let cache_path = registration::default_tags_path();
	let legacy_cache_path = standard_tags_path();

	let (tags, source) = if args.offline {
		match load_cached_tags(&cache_path, &legacy_cache_path)? {
			Some(c) => (c.tags, Source::Cache(c.fetched_at)),
			None => bail!(
				"--offline requested but no cached tags at {} — run online once first",
				cache_path.display()
			),
		}
	} else {
		match fetch_online().await {
			Ok(tags) => {
				if let Err(err) = bestool_tamanu::server_info::save_cached_tags(&tags) {
					warn!(%err, path = %cache_path.display(), "could not save tags cache");
				}
				let _ = config; // suppress unused; kept so we hold the load_config side effects
				(tags, Source::Live)
			}
			Err(err) => {
				warn!(%err, "canopy fetch failed; falling back to cache");
				match load_cached_tags(&cache_path, &legacy_cache_path)? {
					Some(c) => (c.tags, Source::Cache(c.fetched_at)),
					None => return Err(err.wrap_err(format!(
						"canopy unreachable and no cached tags at {}",
						cache_path.display()
					))),
				}
			}
		}
	};

	let use_colours = ctx.require::<Args>().logging.color.enabled();
	if args.json {
		render_json(&tags, source)
	} else {
		render_text(&tags, source, use_colours)
	}
}

#[derive(Debug, Clone, Copy)]
enum Source {
	Live,
	Cache(Option<jiff::Timestamp>),
}

async fn fetch_online() -> Result<BTreeMap<String, String>> {
	let device_key = bestool_tamanu::server_info::fetch_device_key().await;
	let canopy = CanopyClient::new(device_key.as_deref(), crate::http::client_builder)
		.await?
		.ok_or_else(|| miette!("no canopy auth path: no tailscale, no device key"))?;

	debug!(
		via = if canopy.is_tailscale().await { "tailscale" } else { "mtls" },
		"fetching tags"
	);

	// Returns the merged server+group tag map. An error (including a non-2xx)
	// propagates to the caller, which falls back to the cache.
	let tags = canopy.tags().await.wrap_err("GET /tags via canopy")?;
	Ok(tags.0.into_iter().collect())
}

fn render_json(tags: &BTreeMap<String, String>, source: Source) -> Result<()> {
	let payload = serde_json::json!({
		"tags": tags,
		"source": match source {
			Source::Live => "live",
			Source::Cache(_) => "cache",
		},
		"cachedAt": match source {
			Source::Cache(Some(t)) => Some(t.to_string()),
			_ => None,
		},
	});
	let stdout = std::io::stdout();
	serde_json::to_writer_pretty(stdout.lock(), &payload).into_diagnostic()?;
	println!();
	Ok(())
}

fn render_text(tags: &BTreeMap<String, String>, source: Source, use_colours: bool) -> Result<()> {
	if tags.is_empty() {
		println!("(no tags assigned)");
	} else {
		let mut table = Table::new();
		table.load_preset(NOTHING);
		table.set_header(Row::from(vec!["Tag", "Value"]));
		for (key, value) in tags {
			table.add_row(Row::from(vec![key.clone(), value.clone()]));
		}
		println!("{table}");
	}

	match source {
		Source::Live => {}
		Source::Cache(Some(t)) => {
			let note = format!("(cached from {t}; canopy unreachable)");
			if use_colours {
				use owo_colors::OwoColorize as _;
				println!("{}", note.dimmed());
			} else {
				println!("{note}");
			}
		}
		Source::Cache(None) => {
			let note = "(cached; canopy unreachable, age unknown)";
			if use_colours {
				use owo_colors::OwoColorize as _;
				println!("{}", note.dimmed());
			} else {
				println!("{note}");
			}
		}
	}
	Ok(())
}

/// Load the tags cache from `primary`, falling back to `legacy` when the
/// primary file is absent (a host that predates the move to the registration
/// directory).
fn load_cached_tags(primary: &Path, legacy: &Path) -> Result<Option<TagsCache>> {
	match load_cache(primary)? {
		Some(cache) => Ok(Some(cache)),
		None => load_cache(legacy),
	}
}

fn load_cache(path: &Path) -> Result<Option<TagsCache>> {
	match std::fs::read(path) {
		Ok(bytes) => match serde_json::from_slice::<TagsCache>(&bytes) {
			Ok(c) => Ok(Some(c)),
			Err(err) => {
				warn!(%err, path = %path.display(), "ignoring unparseable tags cache");
				Ok(None)
			}
		},
		Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
		Err(err) => Err(err)
			.into_diagnostic()
			.wrap_err_with(|| format!("reading tags cache at {}", path.display())),
	}
}

#[cfg(test)]
mod tests {
	use super::*;

	fn write_cache(path: &Path, tag: &str) {
		let mut tags = BTreeMap::new();
		tags.insert("role".to_owned(), tag.to_owned());
		let cache = TagsCache {
			tags,
			fetched_at: None,
		};
		std::fs::write(path, serde_json::to_vec(&cache).unwrap()).unwrap();
	}

	#[test]
	fn load_cached_tags_prefers_primary_then_falls_back_to_legacy() {
		let dir = tempfile::tempdir().unwrap();
		let primary = dir.path().join("tags.json");
		let legacy = dir.path().join("legacy-tags.json");

		// Neither present: nothing to load.
		assert!(load_cached_tags(&primary, &legacy).unwrap().is_none());

		// Only the legacy file: read it (a host that hasn't refreshed since the
		// move to the registration directory).
		write_cache(&legacy, "legacy");
		let from_legacy = load_cached_tags(&primary, &legacy).unwrap().unwrap();
		assert_eq!(from_legacy.tags.get("role").map(String::as_str), Some("legacy"));

		// Primary present: it wins over the legacy file.
		write_cache(&primary, "primary");
		let from_primary = load_cached_tags(&primary, &legacy).unwrap().unwrap();
		assert_eq!(
			from_primary.tags.get("role").map(String::as_str),
			Some("primary")
		);
	}
}
