//! `bestool tamanu 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::{path::Path, sync::Arc};

use bestool_canopy::{CanopyClient, DEFAULT_CANOPY_URL};
use clap::Parser;
use comfy_table::{Row, Table, presets::NOTHING};
use miette::{IntoDiagnostic, Result, WrapErr, bail, miette};
use reqwest::Url;
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, find_tamanu},
};

/// Fetch this device's tags from canopy.
///
/// Tags are stored server-side in canopy and identify what role / fleet /
/// labels this device carries. 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 list.
	#[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 bare tag list 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
/// `Vec<String>`.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TagsCache {
	tags: Vec<String>,
	#[serde(default)]
	fetched_at: Option<jiff::Timestamp>,
}

pub async fn run(args: TagsArgs, ctx: Context) -> Result<()> {
	let (version, root) = find_tamanu(ctx.require::<TamanuArgs>())?;
	let config = Arc::new(load_config(&root, None)?);

	let tamanu_version = version.to_string();
	let cache_path = standard_tags_path();

	let (tags, source) = if args.offline {
		match load_cache(&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(&tamanu_version).await {
			Ok(tags) => {
				let cache = TagsCache {
					tags: tags.clone(),
					fetched_at: Some(jiff::Timestamp::now()),
				};
				if let Err(err) = save_cache(&cache_path, &cache) {
					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_cache(&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()
					))),
				}
			}
		}
	};

	if args.json {
		render_json(&tags, source)
	} else {
		render_text(&tags, source, ctx.require::<TamanuArgs>().use_colours)
	}
}

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

async fn fetch_online(tamanu_version: &str) -> Result<Vec<String>> {
	let device_key = read_device_key();
	let canopy = CanopyClient::new(tamanu_version.to_owned(), device_key.as_deref())
		.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"
	);

	let base: Url = DEFAULT_CANOPY_URL
		.parse()
		.into_diagnostic()
		.wrap_err("parsing canopy base URL")?;

	// `/public/tags/self` is reachable from tagged-device tailscale callers
	// (the only mount that admits them); `/tags/self` is the mTLS path on
	// the main canopy host.
	let response = canopy
		.get(&base, "/public/tags/self", "/tags/self")
		.await
		.wrap_err("GET /tags/self via canopy")?;

	let status = response.status();
	if !status.is_success() {
		let body = response.text().await.unwrap_or_default();
		bail!("canopy /tags/self returned {status}: {body}");
	}

	response
		.json::<Vec<String>>()
		.await
		.into_diagnostic()
		.wrap_err("decoding canopy tags response")
}

fn render_json(tags: &[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: &[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"]));
		for t in tags {
			table.add_row(Row::from(vec![t.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(())
}

/// Read the Tamanu device key from the standard on-disk path.
///
/// Mirrors the helper used by `tamanu psql` for snippet fetching — the
/// full DB-fallback fetcher in `bestool-tamanu` pulls in the doctor-only
/// tokio runtime, which we don't need here. Reading the file directly is
/// enough for canopy mTLS auth; absence is OK and just falls back to the
/// tailscale path.
fn read_device_key() -> Option<String> {
	let path = bestool_tamanu::server_info::standard_device_key_path();
	match std::fs::read_to_string(&path) {
		Ok(s) if !s.trim().is_empty() => Some(s),
		_ => None,
	}
}

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())),
	}
}

fn save_cache(path: &Path, cache: &TagsCache) -> Result<()> {
	if let Some(parent) = path.parent()
		&& !parent.exists()
	{
		std::fs::create_dir_all(parent)
			.into_diagnostic()
			.wrap_err_with(|| format!("creating tags cache dir {}", parent.display()))?;
	}
	let json = serde_json::to_vec_pretty(cache)
		.into_diagnostic()
		.wrap_err("serialising tags cache")?;
	let tmp = path.with_extension("json.tmp");
	std::fs::write(&tmp, &json)
		.into_diagnostic()
		.wrap_err_with(|| format!("writing tags cache tempfile at {}", tmp.display()))?;
	std::fs::rename(&tmp, path)
		.into_diagnostic()
		.wrap_err_with(|| format!("renaming tags cache into place at {}", path.display()))?;
	Ok(())
}
