// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Scoped-MixnetStream egress — the MONEY-PATH ANCHOR. When the relay pool
//! advertises a relay operator's CO-LOCATED Nym exit
//! ([`crate::nostr::pool::PoolRelay::exit`]), the wallet dials that exit
//! directly over the mixnet with a [`MixnetStream`]; the exit pipes the bytes
//! to its ONE configured relay. No public DNS, no public IPR — the two flaky
//! dependencies of the fallback path are gone from the money path. The exit is
//! scoped (it forwards nowhere else), so the wallet writes nothing but the TLS
//! ClientHello: the dial sites run the SAME hostname-validated TLS (SNI = the
//! relay host) + websocket/HTTP wrap over this stream as over the smolmix
//! tunnel's TCP stream, and the exit sees only ciphertext.
//!
//! ANCHOR + FALLBACK, never pin-only: every failure here (bad address, client
//! bootstrap, stream open, timeout) just returns `Err`, and the dial sites
//! ([`super::transport`], [`super::request_once`]) fall through to the
//! public-IPR tunnel ([`super::nymproc`]) — losing the operator's exit never
//! locks the wallet out. Server side: the bundled `floonet-mixexit` binary
//! (design in ~/.claude/plans/floonet-nym-exit.md).

use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;

use log::{info, warn};
use nym_sdk::mixnet::{MixnetClient, MixnetStream, Recipient};
use tokio::sync::Mutex;

/// The boxed transport stream handed to the TLS/websocket layer — the same
/// seat the smolmix tunnel's TCP stream occupies on the fallback path
/// (everything that layer needs is the shared [`super::Stream`] trait).
pub(crate) type BoxedStream = Box<dyn super::Stream>;

/// After the Open is SENT, wait this long before handing back a writable
/// stream. `open_stream` returns once the Open message leaves the client, NOT
/// once the exit has `accept()`ed and wired its inbound half. But the caller
/// speaks first (TLS ClientHello over a raw-pipe exit), so a write landing in
/// that gap is dropped and the handshake stalls into a fallback. One mixnet
/// round of slack lets the exit be listening before the first byte.
/// ponytail: fixed settle (measured: 0s always stalls, 3s is reliable). The
/// exit pipes raw bytes to its relay, so it can't inject an accept-ack for the
/// client to wait on; if mixnet jitter ever makes 3s flaky, raise it.
const STREAM_SETTLE: Duration = Duration::from_secs(3);

/// Process-lifetime mixnet client for the scoped-exit egress, lazily connected
/// on first use (mirrors the tunnel singleton in [`super::nymproc`]).
/// Ephemeral in-memory identity, like the tunnel — a fresh mixnet identity per
/// run. Behind an async mutex because `open_stream` needs `&mut`; a dead
/// client (cancelled shutdown token or a failed open) is dropped so the next
/// dial reconnects fresh.
static CLIENT: Mutex<Option<MixnetClient>> = Mutex::const_new(None);

/// True once the exit's `MixnetClient` has bootstrapped and is usable. The
/// cold-start sequencer in [`super::nymproc`] reads this to hold the public-IPR
/// tunnel's bootstrap until the exit client has its Nym bandwidth grant (see the
/// NOTE below), so the money path connects in seconds instead of a minute.
static READY: AtomicBool = AtomicBool::new(false);

/// Whether the scoped-exit mixnet client is bootstrapped and usable.
pub fn is_ready() -> bool {
	READY.load(Ordering::Relaxed)
}

// NOTE ON COLD-START LATENCY (and its fix): the exit rides a SECOND ephemeral
// MixnetClient (separate from the smolmix tunnel). When BOTH clients bootstrap
// at once on a cold start they serialize on Nym free-tier bandwidth grants — so
// whichever dials second waits ~a minute for its grant. The money path must not
// be the loser of that race. Fix (see nymproc's cold-start sequencer): the exit
// client is allowed to grab its grant FIRST, and the tunnel's bootstrap waits a
// bounded head-start for `is_ready()` before it competes — so only ONE client
// bootstraps at a time and the money-path relay connects in seconds. The tunnel
// (fallback / HTTP / discovery, all non-blocking) comes up right after. A
// startup pre-warm of BOTH in parallel does NOT help (measured) — sequencing,
// not parallelism, is what removes the stall. Sharing ONE client for tunnel +
// exit would remove the second grant entirely but couples the robust exit to
// the tunnel's per-reselect client rebuild; deferred as a future upgrade.

/// Open a scoped MixnetStream to `exit` — a pool-advertised Nym address
/// (`<client>.<enc>@<gateway>`) of a relay operator's co-located exit. The
/// whole dial (client bootstrap when cold + stream open) is capped at
/// `min(timeout, BOOTSTRAP_TIMEOUT)` so a stuck bootstrap fails FAST into the
/// caller's public-IPR fallback. NOTE: `open_stream` is fire-and-forget on the
/// mixnet — a DEAD exit still hands back a stream, and its death surfaces at
/// the caller's (timeout-bounded) TLS handshake, which doubles as the
/// liveness probe: no ServerHello through the pipe → fall back.
pub(crate) async fn open_stream(exit: &str, timeout: Duration) -> Result<BoxedStream, String> {
	let recipient: Recipient = exit
		.trim()
		.parse()
		.map_err(|e| format!("invalid exit address: {e}"))?;
	let cap = timeout.min(super::nymproc::BOOTSTRAP_TIMEOUT);
	let stream = match tokio::time::timeout(cap, open(recipient)).await {
		Ok(result) => result?,
		Err(_) => return Err(format!("exit dial exceeded {}s", cap.as_secs())),
	};
	// Let the exit accept() + wire its inbound half before the caller writes.
	tokio::time::sleep(STREAM_SETTLE).await;
	Ok(Box::new(stream) as BoxedStream)
}

/// Bootstrap the shared client ahead of the first dial. The cold-start
/// sequencer in [`super::nymproc`] spawns this when the pool advertises an
/// exit: without it the client would only bootstrap on the first relay dial —
/// which happens after a wallet opens, so the tunnel's bounded head-start wait
/// would just expire and both clients would race for their bandwidth grants
/// anyway. Failure is non-fatal (the first real dial retries the bootstrap).
pub async fn prewarm() {
	if let Err(e) = ensure_client().await {
		warn!("nym: streamexit prewarm failed: {e}");
	}
}

/// Ensure the shared client is connected (bootstrapping it when absent or
/// dead) and READY reflects reality. Holds the client lock across the
/// bootstrap so concurrent callers coalesce onto one connect.
async fn ensure_client() -> Result<(), String> {
	let mut guard = CLIENT.lock().await;
	// A dead client (gateway dropped, hosting runtime gone) is discarded and
	// rebuilt — the auto-reconnect-on-drop rule.
	if guard
		.as_ref()
		.is_some_and(|c| c.cancellation_token().is_cancelled())
	{
		warn!("nym: streamexit client died; reconnecting");
		*guard = None;
		READY.store(false, Ordering::Relaxed);
	}
	if guard.is_none() {
		let started = std::time::Instant::now();
		let client = MixnetClient::connect_new()
			.await
			.map_err(|e| format!("mixnet client bootstrap failed: {e}"))?;
		info!(
			"[timing] nym: streamexit client CONNECTED in {}ms",
			started.elapsed().as_millis()
		);
		*guard = Some(client);
		READY.store(true, Ordering::Relaxed);
	}
	Ok(())
}

/// Ensure the shared client is connected, then open a stream on it.
async fn open(recipient: Recipient) -> Result<MixnetStream, String> {
	ensure_client().await?;
	let mut guard = CLIENT.lock().await;
	// Re-acquired the lock after ensure_client — a concurrent failed dial may
	// have dropped the client in between; error into the caller's fallback
	// rather than panic.
	let Some(client) = guard.as_mut() else {
		return Err("exit client lost before dial".to_string());
	};
	match client.open_stream(recipient, None).await {
		Ok(stream) => Ok(stream),
		Err(e) => {
			// `open_stream` fails only LOCALLY (the client's input channel) —
			// it never waits on the peer — so an error means the client itself
			// is broken, not the exit. Drop it; the next dial reconnects.
			*guard = None;
			READY.store(false, Ordering::Relaxed);
			Err(format!("open_stream failed: {e}"))
		}
	}
}

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

	#[tokio::test]
	async fn bad_exit_address_fails_fast_without_touching_the_mixnet() {
		// The address parse runs BEFORE any client bootstrap, so garbage from
		// a hostile pool costs nothing and degrades to the fallback path.
		let err = open_stream("not-a-recipient", Duration::from_secs(5))
			.await
			.err()
			.expect("garbage address must fail");
		assert!(err.contains("invalid exit address"), "got: {err}");
	}

	/// LIVE end-to-end smoke test of the money path against the DEPLOYED
	/// floonet-mixexit (.8): dial the pinned pool's `exit` for relay.goblin.st
	/// over the mixnet with the real [`open_stream`], run the SAME
	/// hostname-validated TLS + websocket wrap the wallet uses
	/// ([`super::super::transport`]), then send a nostr REQ and require the
	/// relay to answer (EVENT/EOSE). Proves mixnet -> exit -> relay:443 ->
	/// nostr actually carries traffic. Ignored (needs network + a cold mixnet
	/// bootstrap). Run:
	///   cargo test --lib nym::streamexit::tests::live_exit_roundtrip -- --ignored --nocapture
	#[tokio::test]
	#[ignore]
	async fn live_exit_roundtrip() {
		use futures::{SinkExt, StreamExt};
		use tokio_tungstenite::tungstenite::Message;

		// The app installs this at startup (src/lib.rs); an isolated test must
		// too, or rustls 0.23 can't pick a provider for the TLS handshake.
		let _ = rustls::crypto::ring::default_provider().install_default();

		let exit = crate::nostr::pool::load()
			.exit_for("wss://relay.floonet.dev")
			.expect("pinned pool advertises the relay.floonet.dev exit");
		println!("dialing scoped exit {exit}");

		// A cold ephemeral mixnet bootstrap can exceed the per-dial cap; the
		// real wallet just falls back and retries, so retry until one dial wins.
		let mut stream = None;
		for attempt in 1..=6 {
			let t = std::time::Instant::now();
			match open_stream(&exit, Duration::from_secs(90)).await {
				Ok(s) => {
					println!(
						"open_stream OK on attempt {attempt} in {}ms",
						t.elapsed().as_millis()
					);
					stream = Some(s);
					break;
				}
				Err(e) => println!(
					"attempt {attempt} failed in {}ms: {e}",
					t.elapsed().as_millis()
				),
			}
		}
		let stream = stream.expect("exit stream opened within retries");

		let url = "wss://relay.floonet.dev";
		let (mut ws, _resp) = tokio::time::timeout(
			Duration::from_secs(45),
			tokio_tungstenite::client_async_tls(url, stream),
		)
		.await
		.expect("TLS+ws handshake timed out (dead exit?)")
		.expect("TLS+ws handshake through exit failed");
		println!("TLS+ws handshake through .8 exit OK");

		ws.send(Message::Text(
			r#"["REQ","smoke",{"kinds":[1],"limit":1}]"#.into(),
		))
		.await
		.expect("send REQ");

		let reply = tokio::time::timeout(Duration::from_secs(30), ws.next())
			.await
			.expect("relay reply timed out")
			.expect("ws stream closed early")
			.expect("ws frame error");
		let txt = match reply {
			Message::Text(t) => t.to_string(),
			other => format!("{other:?}"),
		};
		println!("relay answered through exit: {txt}");
		assert!(
			txt.contains("EVENT") || txt.contains("EOSE"),
			"unexpected relay reply: {txt}"
		);
	}

	/// INCIDENT REPRO / VERIFICATION harness: publish a ~2.5KB and a ~66KB
	/// kind-1059 EVENT over a SCRATCH scoped exit (address from env
	/// `GOBLIN_SCRATCH_EXIT`) to relay.floonet.dev, plus a clearnet control, and
	/// report which land (clearnet oracle = ground truth, waits past EOSE so a
	/// LATE arrival is still caught). Proves whether the exit pump forwards
	/// multi-fragment writes. Run:
	///   GOBLIN_SCRATCH_EXIT=<addr> cargo test --lib \
	///     nym::streamexit::tests::scratch_exit_publish_bytes -- --ignored --nocapture
	#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
	#[ignore]
	async fn scratch_exit_publish_bytes() {
		use futures::{SinkExt, StreamExt};
		use nostr_sdk::JsonUtil;
		use nostr_sdk::prelude::*;
		use tokio_tungstenite::tungstenite::Message;

		let _ = rustls::crypto::ring::default_provider().install_default();
		let _ = env_logger::builder()
			.is_test(false)
			.filter_level(log::LevelFilter::Info)
			.filter_module("grim::nym", log::LevelFilter::Debug)
			.try_init();

		let exit = std::env::var("GOBLIN_SCRATCH_EXIT")
			.expect("set GOBLIN_SCRATCH_EXIT to the scratch exit's nym address");
		let relay_url = "wss://relay.floonet.dev";

		let keys = Keys::generate();
		let mk = |n: usize| -> Event {
			let nonce = format!("{:016x}", rand::random::<u64>());
			EventBuilder::new(Kind::GiftWrap, format!("{nonce}{}", "x".repeat(n)))
				.tag(Tag::public_key(keys.public_key()))
				.sign_with_keys(&keys)
				.expect("sign event")
		};
		let small = mk(2_000);
		let big = mk(64_000);
		let clear = mk(2_000);
		println!(
			"[repro] small id={} wire={}B | big id={} wire={}B | clear id={} wire={}B",
			small.id.to_hex(),
			small.as_json().len(),
			big.id.to_hex(),
			big.as_json().len(),
			clear.id.to_hex(),
			clear.as_json().len()
		);

		// Clearnet control FIRST (proves the events + relay are fine end to end).
		let clear_ok = clearnet_publish(relay_url, &clear).await;
		println!("[repro] clearnet publish OK-frame for clear = {clear_ok}");

		// Open the SCRATCH scoped exit and run the SAME TLS+ws the wallet uses.
		let mut stream = None;
		for attempt in 1..=6 {
			match open_stream(&exit, Duration::from_secs(90)).await {
				Ok(s) => {
					println!("[repro] open_stream OK on attempt {attempt}");
					stream = Some(s);
					break;
				}
				Err(e) => println!("[repro] open_stream attempt {attempt} failed: {e}"),
			}
		}
		let stream = stream.expect("scratch exit stream opened within retries");
		let (mut ws, _resp) = tokio::time::timeout(
			Duration::from_secs(45),
			tokio_tungstenite::client_async_tls(relay_url, stream),
		)
		.await
		.expect("TLS+ws handshake timed out (dead exit?)")
		.expect("TLS+ws handshake through scratch exit failed");
		println!("[repro] TLS+ws through scratch exit OK");

		for (label, ev) in [("small", &small), ("big", &big)] {
			let frame = format!(r#"["EVENT",{}]"#, ev.as_json());
			println!("[repro] EXIT sending {label} ({} B ws frame)", frame.len());
			ws.send(Message::Text(frame.into()))
				.await
				.expect("ws send over exit");
		}

		// Keep draining the exit ws in the background so the relay->client OK path
		// keeps moving while we measure landing time.
		let drainer = tokio::spawn(async move {
			let end = tokio::time::Instant::now() + Duration::from_secs(300);
			while tokio::time::Instant::now() < end {
				match tokio::time::timeout(Duration::from_secs(5), ws.next()).await {
					Ok(Some(Ok(Message::Text(t)))) => {
						println!("[repro] EXIT relay -> {}", t.as_str())
					}
					Ok(Some(Ok(_))) => {}
					Ok(Some(Err(_))) | Ok(None) => break,
					Err(_) => {}
				}
			}
		});

		// Measure delivery LATENCY via the clearnet oracle (waits past EOSE).
		let t0 = tokio::time::Instant::now();
		let probe = Duration::from_secs(180);
		let small_id = small.id.to_hex();
		let big_id = big.id.to_hex();
		let small_fut = async {
			let ok = oracle_landed(relay_url, &small_id, probe).await;
			println!(
				"[repro] ===== EXIT small landed={ok} after {}s =====",
				t0.elapsed().as_secs()
			);
			ok
		};
		let big_fut = async {
			let ok = oracle_landed(relay_url, &big_id, probe).await;
			println!(
				"[repro] ===== EXIT big landed={ok} after {}s =====",
				t0.elapsed().as_secs()
			);
			ok
		};
		let (_s, _b) = tokio::join!(small_fut, big_fut);

		let clear_landed =
			oracle_landed(relay_url, &clear.id.to_hex(), Duration::from_secs(20)).await;
		println!("[repro] ===== CLEARNET control clear landed={clear_landed} =====");
		drainer.abort();
	}

	/// Clearnet publish `ev`; returns true on relay `OK ... true`. Positive control.
	#[cfg(test)]
	async fn clearnet_publish(url: &str, ev: &nostr_sdk::Event) -> bool {
		use futures::{SinkExt, StreamExt};
		use nostr_sdk::JsonUtil;
		use tokio_tungstenite::tungstenite::Message;
		let (mut ws, _) = match tokio_tungstenite::connect_async(url).await {
			Ok(x) => x,
			Err(e) => {
				println!("[oracle] clearnet connect err: {e}");
				return false;
			}
		};
		let frame = format!(r#"["EVENT",{}]"#, ev.as_json());
		if ws.send(Message::Text(frame.into())).await.is_err() {
			return false;
		}
		let id = ev.id.to_hex();
		for _ in 0..20 {
			match tokio::time::timeout(Duration::from_secs(10), ws.next()).await {
				Ok(Some(Ok(Message::Text(t)))) => {
					let t = t.as_str();
					if t.starts_with("[\"OK\"") {
						println!("[oracle] clearnet OK-frame: {t}");
						return t.contains(&id) && t.contains("true");
					}
				}
				_ => break,
			}
		}
		false
	}

	/// Clearnet oracle: REQ for `id_hex`; true iff the relay returns the stored
	/// EVENT within `timeout`. Ignores EOSE and keeps the sub OPEN so a LATE
	/// arrival (the slow-exit case) is caught the instant the relay stores it.
	#[cfg(test)]
	async fn oracle_landed(url: &str, id_hex: &str, timeout: Duration) -> bool {
		use futures::{SinkExt, StreamExt};
		use tokio_tungstenite::tungstenite::Message;
		let (mut ws, _) = match tokio_tungstenite::connect_async(url).await {
			Ok(x) => x,
			Err(e) => {
				println!("[oracle] connect err: {e}");
				return false;
			}
		};
		let req = format!(r#"["REQ","oracle",{{"ids":["{id_hex}"]}}]"#);
		if ws.send(Message::Text(req.into())).await.is_err() {
			return false;
		}
		let deadline = tokio::time::Instant::now() + timeout;
		loop {
			let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
			if remaining.is_zero() {
				return false;
			}
			match tokio::time::timeout(remaining, ws.next()).await {
				Ok(Some(Ok(Message::Text(t)))) => {
					if t.as_str().starts_with("[\"EVENT\"") {
						return true;
					}
				}
				Ok(Some(Ok(_))) => {}
				_ => return false,
			}
		}
	}
}
