//! Local control channel between the desktop IDE and the bridge
//! (Phase 13.4b).
//!
//! The bridge is a separate process that owns state the IDE can't see
//! directly — the live pairing code (in memory) and the keyring
//! device list. Earlier this crossed the process boundary via files
//! (`companion-status.json` / `companion-revoke.json`), but a file is
//! state without a heartbeat: a crashed/killed bridge leaves a stale
//! "running" file behind, so the IDE had to TCP-probe the port to
//! tell if the bridge was really alive.
//!
//! Instead the bridge listens on a Unix socket at
//! `<bridge_dir>/control.sock` and answers `status` / `revoke` /
//! `shutdown` requests. Liveness is intrinsic: if the IDE's
//! `connect()` succeeds, the bridge is alive; if it's refused, it
//! isn't. No file to go stale, same single-instance-detection shape
//! as the per-workspace `instance.sock` (ADR 0014). The bridge stays
//! the sole keyring writer — the IDE only *asks* it to revoke.
//!
//! Wire format: one compact-JSON [`ControlRequest`] line in, one
//! compact-JSON [`ControlResponse`] line out, connection closed.

use serde::{Deserialize, Serialize};

/// Control socket filename under the bridge dir.
pub const CONTROL_SOCK: &str = "control.sock";

/// What the IDE asks the bridge to do.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "lowercase")]
pub enum ControlRequest {
	/// Report current pairing payload + paired devices + enrolled IDEs.
	Status,
	/// Revoke a paired device by id.
	Revoke { device_id: String },
	/// Revoke an enrolled IDE by id (Phase 14, ADR 0031). Mirror of
	/// `Revoke` for the IDE↔bridge relationship.
	RevokeIde { ide_id: String },
	/// Mint a fresh phone-pairing code (Phase 14.5). Pairing is
	/// on-demand everywhere: the local panel asks over this socket,
	/// a remote IDE over its enrolled WS. There is no startup window.
	PairCode,
	/// Ask the bridge to exit (e.g. before a rebuild).
	Shutdown,
}

/// The bridge's reply.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum ControlResponse {
	Status(CompanionStatus),
	/// `revoked: true` if a device was removed, false if unknown id.
	Revoked {
		revoked: bool,
	},
	/// A freshly-minted phone-pairing payload (reply to `PairCode`,
	/// Phase 14.5). `payload` is the compact JSON the QR encodes.
	PairCode {
		payload: String,
		url: String,
		code: String,
		fingerprint: String,
	},
	Ok,
	Error {
		message: String,
	},
}

/// The companion state the IDE's panel renders. `running` is implied
/// by a successful connect now, but kept in the struct so the IDE has
/// one shape to hold (set true when the response arrives).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CompanionStatus {
	pub running: bool,
	/// The `wss://…` URL phones connect to. Pairing codes are minted
	/// on demand (`PairCode`), so there is no startup payload here.
	pub url: String,
	/// `.local` URL when mDNS is up.
	pub mdns_url: Option<String>,
	pub fingerprint: String,
	pub devices: Vec<DeviceEntry>,
	/// Enrolled IDEs (Phase 14, ADR 0031). Mirror of `devices` for
	/// the IDE↔bridge relationship. The IDE's Companion panel renders
	/// this list with revoke, alongside the paired-devices list.
	#[serde(default)]
	pub ides: Vec<IdeEntry>,
	/// Hash of this bridge's own executable. The IDE compares it to
	/// the binary it's about to stage: same build → leave the running
	/// bridge alone (another window started it); different → it's
	/// stale, evict + respawn. Self-maintaining, no version bumps.
	#[serde(default)]
	pub build_id: String,
	/// Count of phone WS connections currently connected to this
	/// bridge. Drives the IDE's status-bar "companion (N)" badge.
	#[serde(default)]
	pub connected_phones: u64,
}

/// Hash the running executable to identify its build. Cheap (one read
/// of the binary at startup) and good enough to tell "same build" from
/// "different build" — we only need inequality detection, not crypto.
pub fn self_build_id() -> String {
	let Ok(exe) = std::env::current_exe() else {
		return String::new();
	};
	let Ok(bytes) = std::fs::read(&exe) else {
		return String::new();
	};
	// FNV-1a 64-bit over the binary; hex. Collisions across two
	// different moon-bridge builds are astronomically unlikely and the
	// failure mode (not evicting a stale bridge) is the pre-existing
	// behaviour, so a non-crypto hash is fine.
	let mut hash: u64 = 0xcbf29ce484222325;
	for b in bytes {
		hash ^= b as u64;
		hash = hash.wrapping_mul(0x100000001b3);
	}
	format!("{hash:016x}")
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceEntry {
	pub id: String,
	pub label: String,
	pub paired_at_ms: u128,
}

/// One enrolled IDE (Phase 14). Mirror of [`DeviceEntry`] for the
/// IDE↔bridge relationship.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdeEntry {
	pub id: String,
	pub label: String,
	pub enrolled_at_ms: u128,
}

/// Path of the control socket under `bridge_dir`.
pub fn control_sock_path(bridge_dir: &camino::Utf8Path) -> std::path::PathBuf {
	bridge_dir.join(CONTROL_SOCK).into_std_path_buf()
}

/// Encode a response as a single framed line for the server side.
pub fn encode_response(resp: &ControlResponse) -> Vec<u8> {
	let mut line = serde_json::to_vec(resp).unwrap_or_else(|_| br#"{"kind":"error","message":"encode failed"}"#.to_vec());
	line.push(b'\n');
	line
}

/// Parse one framed request line on the server side.
pub fn parse_request(buf: &[u8]) -> Option<ControlRequest> {
	let end = buf.iter().position(|&b| b == b'\n')?;
	serde_json::from_slice(&buf[..end]).ok()
}

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

	#[test]
	fn request_round_trips() {
		for req in [
			ControlRequest::Status,
			ControlRequest::Revoke {
				device_id: "abc".into(),
			},
			ControlRequest::RevokeIde {
				ide_id: "eli-laptop".into(),
			},
			ControlRequest::Shutdown,
		] {
			let mut line = serde_json::to_vec(&req).unwrap();
			line.push(b'\n');
			let parsed = parse_request(&line).unwrap();
			// Compare via serialised form (no PartialEq needed).
			assert_eq!(
				serde_json::to_string(&parsed).unwrap(),
				serde_json::to_string(&req).unwrap()
			);
		}
	}

	#[test]
	fn response_round_trips() {
		let resp = ControlResponse::Status(CompanionStatus {
			running: true,
			fingerprint: "aa:bb".into(),
			..Default::default()
		});
		let bytes = encode_response(&resp);
		assert!(bytes.ends_with(b"\n"));
		let end = bytes.iter().position(|&b| b == b'\n').unwrap();
		let back: ControlResponse = serde_json::from_slice(&bytes[..end]).unwrap();
		assert!(matches!(back, ControlResponse::Status(s) if s.running && s.fingerprint == "aa:bb"));
	}
}
