//! The `dev` toolset: a relay and a room service on loopback, run as the room service's `dev`
//! mode (`wai-room dev`, a child process driven by JSON control lines) by `dev_start` and
//! `dev_stop`; and on that room, the target `local`, grants minted for test principals
//! (`room_grant`), those principals joining, publishing and leaving (`room_join`, `room_leave`),
//! and the room's session as a verifier is given it (`session_show`).
//!
//! **Development only.** The room binds 127.0.0.1 alone and its keys live only in the child
//! process. A grant is asked for over HTTP from a service whose address is a loopback address,
//! and from no other: the guard is [`loopback_service`], applied before any connection is made,
//! with no name resolved. A test principal's key is SHA-256 of its name under a fixed label, so
//! the same name is the same key in every run, and is never a key anyone should hold.

use super::tools::{name_of, opt_str, str_arg, unhex};
use super::Server;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::mpsc::{channel, Receiver};
use std::time::Duration;

/// How long the room has to start, and each control line to be answered (a join waits for the
/// clock's records, up to 20 s, inside the child).
const START: Duration = Duration::from_secs(60);
const ANSWER: Duration = Duration::from_secs(45);

/// A test principal: its name's key, and the grant last minted for it.
pub(crate) struct Principal {
    seed: [u8; 32],
    public: [u8; 32],
    grant: Option<(String, Vec<u8>)>,
}

impl Principal {
    fn named(name: &str) -> Principal {
        let mut h = Sha256::new();
        h.update(b"wai mcp development principal\x00");
        h.update(name.as_bytes());
        let seed: [u8; 32] = h.finalize().into();
        let public = ed25519_dalek::SigningKey::from_bytes(&seed).verifying_key().to_bytes();
        Principal { seed, public, grant: None }
    }
}

/// The running development room: the child, its control channel and its `ready` line.
pub(crate) struct Dev {
    child: Child,
    stdin: ChildStdin,
    lines: Receiver<String>,
    ready: Value,
    next: u64,
}

impl Dev {
    fn spawn(bin: &PathBuf, publishers: u64) -> Result<Dev, String> {
        let mut child = Command::new(bin)
            .arg("dev")
            .arg("--publishers")
            .arg(publishers.to_string())
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::inherit())
            .spawn()
            .map_err(|e| format!("{}: {e}", bin.display()))?;
        let stdin = child.stdin.take().expect("piped");
        let stdout = child.stdout.take().expect("piped");
        let (tx, lines) = channel();
        std::thread::spawn(move || {
            for line in BufReader::new(stdout).lines() {
                let Ok(line) = line else { break };
                if tx.send(line).is_err() {
                    break;
                }
            }
        });
        let mut d = Dev { child, stdin, lines, ready: Value::Null, next: 1 };
        let first = d.lines.recv_timeout(START).map_err(|_| {
            let _ = d.child.kill();
            format!("{} dev did not start within {} s", bin.display(), START.as_secs())
        })?;
        let ready: Value = serde_json::from_str(&first).map_err(|e| format!("the room's first line is not JSON: {e}"))?;
        if ready["ready"] != json!(true) {
            let _ = d.child.kill();
            return Err(format!("the room did not start: {}", ready["error"].as_str().unwrap_or(&first)));
        }
        d.ready = ready;
        Ok(d)
    }

    /// One control line and its answer.
    fn ask(&mut self, mut line: Value) -> Result<Value, String> {
        let id = self.next;
        self.next += 1;
        line["id"] = json!(id);
        writeln!(self.stdin, "{line}").and_then(|_| self.stdin.flush()).map_err(|e| format!("the development room has stopped: {e}"))?;
        loop {
            let got = self.lines.recv_timeout(ANSWER).map_err(|_| "the development room did not answer in time".to_string())?;
            let v: Value = serde_json::from_str(&got).map_err(|e| format!("the room answered with a line that is not JSON: {e}"))?;
            if v["id"] == json!(id) {
                return if v["ok"] == json!(true) {
                    let mut v = v;
                    if let Some(o) = v.as_object_mut() {
                        o.remove("id");
                        o.remove("ok");
                    }
                    Ok(v)
                } else {
                    Err(v["error"].as_str().unwrap_or("the room refused it").to_string())
                };
            }
        }
    }

    /// Stop the room and wait for the child.
    pub(crate) fn stop(mut self) {
        let _ = writeln!(self.stdin, "{}", json!({"op": "stop"}));
        let _ = self.stdin.flush();
        for _ in 0..100 {
            if let Ok(Some(_)) = self.child.try_wait() {
                return;
            }
            std::thread::sleep(Duration::from_millis(50));
        }
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

fn dev_mut(server: &mut Server) -> Result<&mut Dev, String> {
    server.dev.as_mut().ok_or_else(|| format!("no development room is running: {} starts one", name_of(start)))
}

/// The room service program: the argument, `--room-bin` or `WAI_ROOM_BIN`, or `joule-moq-room`
/// beside this binary, then on `PATH`, as `wai dev` finds it.
fn room_bin(server: &Server, args: &Value) -> Result<PathBuf, String> {
    let given = opt_str(args, "room_bin").map(String::from).or_else(|| server.room_bin.as_ref().map(|p| p.display().to_string()));
    let exe_dir = std::env::current_exe().ok().and_then(|e| std::fs::canonicalize(e).ok()).and_then(|e| e.parent().map(Path::to_path_buf));
    super::super::dev::find_bin("wai-room", given.as_deref(), "WAI_ROOM_BIN", exe_dir.as_deref()).map_err(|e| {
        format!("{e}. The development room runs the room service wai-room (built from joule-code/joule-code-rs/crates/joule-moq-room): give room_bin, --room-bin or WAI_ROOM_BIN")
    })
}

/// The tools of this set act on the target `local` alone: a target named, if any, must be it.
fn local_target(args: &Value) -> Result<(), String> {
    match args.get("target") {
        None => Ok(()),
        Some(Value::String(t)) if t == "local" => Ok(()),
        Some(t) => Err(format!("target {t}: the development tools act on the target local alone (this machine's development room)")),
    }
}

pub(crate) fn start(server: &mut Server, args: &Value) -> Result<Value, String> {
    if let Some(d) = &server.dev {
        let mut v = d.ready.clone();
        v["already_running"] = json!(true);
        return Ok(v);
    }
    let bin = room_bin(server, args)?;
    let publishers = args.get("publishers").and_then(Value::as_u64).unwrap_or(4).clamp(1, 64);
    let d = Dev::spawn(&bin, publishers)?;
    let mut v = d.ready.clone();
    if let Some(o) = v.as_object_mut() {
        o.remove("ready");
    }
    v["development_only"] = json!(true);
    v["warning"] = json!("a development room on loopback: keys in memory, media held to no receipts, rate limits open; never serve it");
    server.dev = Some(d);
    server.principals.clear();
    Ok(v)
}

pub(crate) fn stop(server: &mut Server, _: &Value) -> Result<Value, String> {
    let d = server.dev.take().ok_or("no development room is running")?;
    d.stop();
    server.principals.clear();
    Ok(json!({"stopped": true}))
}

/// The address and path prefix of a room service URL whose host is a loopback address, or
/// the refusal. `localhost` is taken as 127.0.0.1 and no name is resolved: a service on any
/// other host, by any name, is refused before a connection is made.
pub(crate) fn loopback_service(url: &str) -> Result<(SocketAddr, String), String> {
    let refuse = |why: &str| {
        Err(format!(
            "DEVELOPMENT ONLY: development grants are minted on a room service on this machine alone, and {url} {why}. \
             A deployed service grants by its own rules; ask its operator."
        ))
    };
    let Some(rest) = url.strip_prefix("http://") else { return refuse("is not an http:// address on loopback") };
    let (authority, prefix) = match rest.find('/') {
        Some(i) => (&rest[..i], rest[i..].trim_end_matches('/').to_string()),
        None => (rest, String::new()),
    };
    if authority.contains('@') {
        return refuse("carries credentials");
    }
    let (host, port) = match authority.rsplit_once(':') {
        Some((h, p)) if !h.is_empty() && !p.contains(']') => (h, p.parse::<u16>().map_err(|_| format!("{url}: a port"))?),
        _ => (authority, 80),
    };
    let ip: IpAddr = match host {
        "localhost" => IpAddr::V4(Ipv4Addr::LOCALHOST),
        h if h.starts_with('[') && h.ends_with(']') => match h[1..h.len() - 1].parse::<Ipv6Addr>() {
            Ok(a) => IpAddr::V6(a),
            Err(_) => return refuse("does not name a loopback address"),
        },
        h => match h.parse::<Ipv4Addr>() {
            Ok(a) => IpAddr::V4(a),
            Err(_) => return refuse("does not name a loopback address"),
        },
    };
    if !ip.is_loopback() {
        return refuse("is not on a loopback address");
    }
    Ok((SocketAddr::new(ip, port), prefix))
}

/// One HTTP/1.1 POST of JSON to a loopback service: its status and its JSON.
fn post(addr: SocketAddr, path: &str, body: &Value) -> Result<(u16, Value), String> {
    let mut s = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| format!("{addr}: {e}"))?;
    s.set_read_timeout(Some(Duration::from_secs(20))).map_err(|e| e.to_string())?;
    let b = body.to_string();
    write!(s, "POST {path} HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{b}", b.len()).map_err(|e| e.to_string())?;
    let mut out = Vec::new();
    s.read_to_end(&mut out).map_err(|e| format!("{addr}: {e}"))?;
    let text = String::from_utf8_lossy(&out);
    let (head, body) = text.split_once("\r\n\r\n").ok_or("the service's answer is not HTTP")?;
    let status = head.split(' ').nth(1).and_then(|s| s.parse().ok()).ok_or("the service's answer has no status")?;
    Ok((status, serde_json::from_str(body).unwrap_or(Value::Null)))
}

pub(crate) fn grant(server: &mut Server, args: &Value) -> Result<Value, String> {
    local_target(args)?;
    let name = str_arg(args, "principal")?.to_string();
    let role = str_arg(args, "role")?;
    if !matches!(role, "publisher" | "viewer") {
        return Err("role: publisher or viewer".into());
    }
    let service = match opt_str(args, "service") {
        Some(s) => s.to_string(),
        None => dev_mut(server)?.ready["service"].as_str().unwrap_or_default().to_string(),
    };
    // The guard: nothing is sent unless the service is on this machine.
    let (addr, prefix) = loopback_service(&service)?;
    let p = server.principals.entry(name.clone()).or_insert_with(|| Principal::named(&name));
    let (status, v) = post(addr, &format!("{prefix}/grant"), &json!({"principal": super::tools::hex(&p.public), "role": role}))?;
    if status != 200 {
        return Err(format!("the service refused the grant: {status} {v}"));
    }
    let g = v["grant"].as_str().and_then(unhex).ok_or("the service's answer holds no grant")?;
    p.grant = Some((role.to_string(), g));
    Ok(json!({
        "development_only": true,
        "target": "local",
        "principal": name,
        "key": super::tools::hex(&p.public),
        "role": role,
        "grant": v["grant"],
        "digest": v["digest"],
        "exp": v["exp"],
        "room": v["room"]["room"],
        "service": service,
        "note": "a test principal's key is derived from its name: it is never a key to hold",
    }))
}

pub(crate) fn join(server: &mut Server, args: &Value) -> Result<Value, String> {
    local_target(args)?;
    let name = str_arg(args, "principal")?.to_string();
    let objects = args.get("objects").and_then(Value::as_u64).unwrap_or(1).clamp(1, 64);
    let (seed, grant) = {
        let p = server.principals.get(&name).ok_or_else(|| format!("{name} holds no grant: {} mints one", name_of(grant)))?;
        match &p.grant {
            Some((role, g)) if role == "publisher" => (p.seed, g.clone()),
            _ => return Err(format!("{name} holds no publisher's grant: {} mints one with role publisher", name_of(grant))),
        }
    };
    let mut v = dev_mut(server)?.ask(json!({"op": "join", "seed": super::tools::hex(&seed), "grant": super::tools::hex(&grant), "objects": objects}))?;
    v["development_only"] = json!(true);
    v["name"] = json!(name);
    Ok(v)
}

pub(crate) fn leave(server: &mut Server, args: &Value) -> Result<Value, String> {
    local_target(args)?;
    let name = str_arg(args, "principal")?.to_string();
    let public = server.principals.get(&name).map(|p| p.public).ok_or_else(|| format!("no test principal {name}"))?;
    let mut v = dev_mut(server)?.ask(json!({"op": "leave", "principal": super::tools::hex(&public)}))?;
    v["development_only"] = json!(true);
    v["name"] = json!(name);
    Ok(v)
}

pub(crate) fn session(server: &mut Server, args: &Value) -> Result<Value, String> {
    local_target(args)?;
    let mut v = dev_mut(server)?.ask(json!({"op": "session"}))?;
    v["development_only"] = json!(true);
    Ok(v)
}

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

    #[test]
    fn only_a_loopback_service_is_taken() {
        for ok in ["http://127.0.0.1:9682", "http://127.0.0.1:9682/room-api", "http://localhost:1/", "http://[::1]:80", "http://127.9.9.9"] {
            assert!(loopback_service(ok).is_ok(), "{ok}");
        }
        assert_eq!(loopback_service("http://127.0.0.1:9682/room-api/").unwrap().1, "/room-api");
        for no in [
            "https://127.0.0.1:9682",
            "http://10.0.0.5:9682",
            "http://192.168.4.37",
            "http://room.example",
            "http://localhost.example:80",
            "http://127.0.0.1.example",
            "http://user@127.0.0.1:80",
            "http://[2001:db8::1]:80",
            "ws://127.0.0.1:1",
            "127.0.0.1:9682",
        ] {
            let e = loopback_service(no).expect_err(no);
            assert!(e.starts_with("DEVELOPMENT ONLY"), "{e}");
        }
    }

    #[test]
    fn a_name_is_the_same_key_every_time() {
        assert_eq!(Principal::named("alice").public, Principal::named("alice").public);
        assert_ne!(Principal::named("alice").public, Principal::named("bob").public);
    }
}
