//! flrig.rs — Dual-CSS HF Mode
//! Minimal flrig XML-RPC client for CAT/PTT control.
//!
//! flrig exposes an XML-RPC server (default 127.0.0.1:12345). We only need a
//! couple of methods (`rig.set_ptt`, `rig.get_ptt`, `rig.get_info`), and flrig
//! is always a local process, so rather than pull in a full HTTP + XML-RPC crate
//! stack we speak the tiny subset of XML-RPC-over-HTTP/1.1 directly on a
//! TcpStream. This keeps the dependency list unchanged.
//!
//! XML-RPC request shape:
//!   POST /RPC2 HTTP/1.1 ... <?xml ...><methodCall>
//!     <methodName>rig.set_ptt</methodName>
//!     <params><param><value><i4>1</i4></value></param></params>
//!   </methodCall>

use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;

pub struct FlrigClient {
    host: String,
    port: u16,
}

impl FlrigClient {
    pub fn new(host: &str, port: u16) -> Self {
        Self {
            host: host.to_string(),
            port,
        }
    }

    /// Key (true) or unkey (false) PTT via `rig.set_ptt`.
    pub fn set_ptt(&self, on: bool) -> Result<(), String> {
        let body = xmlrpc_call("rig.set_ptt", &format!("<i4>{}</i4>", on as i32));
        self.send(&body).map(|_| ())
    }

    /// Read PTT state via `rig.get_ptt` (returns true if transmitting).
    #[allow(dead_code)] // rig-state read: API completeness, not yet polled by UI
    pub fn get_ptt(&self) -> Result<bool, String> {
        let body = xmlrpc_call("rig.get_ptt", "");
        let resp = self.send(&body)?;
        // Response carries an <i4> or <int> value; treat non-zero as keyed.
        Ok(extract_int(&resp).unwrap_or(0) != 0)
    }

    /// Probe connectivity via `rig.get_info`; returns the rig info string.
    pub fn get_info(&self) -> Result<String, String> {
        let body = xmlrpc_call("rig.get_info", "");
        let resp = self.send(&body)?;
        Ok(extract_string(&resp).unwrap_or_default())
    }

    /// Quick reachability check used by the GUI "Test" button.
    pub fn ping(&self) -> Result<(), String> {
        self.get_info().map(|_| ())
    }

    fn send(&self, body: &str) -> Result<String, String> {
        let addr = format!("{}:{}", self.host, self.port);
        let mut stream =
            TcpStream::connect(&addr).map_err(|e| format!("flrig connect {addr} failed: {e}"))?;
        stream
            .set_read_timeout(Some(Duration::from_millis(800)))
            .ok();
        stream
            .set_write_timeout(Some(Duration::from_millis(800)))
            .ok();

        let request = format!(
            "POST /RPC2 HTTP/1.1\r\n\
             Host: {host}:{port}\r\n\
             Content-Type: text/xml\r\n\
             Content-Length: {len}\r\n\
             Connection: close\r\n\
             \r\n\
             {body}",
            host = self.host,
            port = self.port,
            len = body.len(),
            body = body,
        );
        stream
            .write_all(request.as_bytes())
            .map_err(|e| format!("flrig write failed: {e}"))?;

        let mut resp = String::new();
        stream
            .read_to_string(&mut resp)
            .map_err(|e| format!("flrig read failed: {e}"))?;

        if let Some(fault) = extract_fault(&resp) {
            return Err(format!("flrig fault: {fault}"));
        }
        Ok(resp)
    }
}

fn xmlrpc_call(method: &str, value_xml: &str) -> String {
    let params = if value_xml.is_empty() {
        "<params></params>".to_string()
    } else {
        format!("<params><param><value>{value_xml}</value></param></params>")
    };
    format!(
        "<?xml version=\"1.0\"?>\
         <methodCall><methodName>{method}</methodName>{params}</methodCall>"
    )
}

/// Extract text between the first `<tag>` and `</tag>` (naive, sufficient for
/// flrig's flat single-value responses).
fn extract_tag<'a>(xml: &'a str, tag: &str) -> Option<&'a str> {
    let open = format!("<{tag}>");
    let close = format!("</{tag}>");
    let start = xml.find(&open)? + open.len();
    let end = xml[start..].find(&close)? + start;
    Some(&xml[start..end])
}

fn extract_int(xml: &str) -> Option<i64> {
    extract_tag(xml, "i4")
        .or_else(|| extract_tag(xml, "int"))
        .and_then(|s| s.trim().parse().ok())
}

fn extract_string(xml: &str) -> Option<String> {
    // <string>…</string>, or a bare <value>…</value> per XML-RPC spec.
    extract_tag(xml, "string")
        .map(|s| s.to_string())
        .or_else(|| extract_tag(xml, "value").map(|s| s.trim().to_string()))
}

fn extract_fault(xml: &str) -> Option<String> {
    if xml.contains("<fault>") {
        extract_tag(xml, "string")
            .map(|s| s.to_string())
            .or(Some("unknown XML-RPC fault".to_string()))
    } else {
        None
    }
}

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

    #[test]
    fn builds_call_with_param() {
        let body = xmlrpc_call("rig.set_ptt", "<i4>1</i4>");
        assert!(body.contains("<methodName>rig.set_ptt</methodName>"));
        assert!(body.contains("<i4>1</i4>"));
    }

    #[test]
    fn builds_call_no_param() {
        let body = xmlrpc_call("rig.get_info", "");
        assert!(body.contains("<params></params>"));
    }

    #[test]
    fn parses_int_response() {
        let xml = "<methodResponse><params><param><value><i4>1</i4></value></param></params></methodResponse>";
        assert_eq!(extract_int(xml), Some(1));
    }

    #[test]
    fn parses_string_response() {
        let xml = "<value><string>IC-7300</string></value>";
        assert_eq!(extract_string(xml).as_deref(), Some("IC-7300"));
    }

    #[test]
    fn detects_fault() {
        let xml = "<methodResponse><fault><value><struct><member><name>faultString</name><value><string>bad</string></value></member></struct></value></fault></methodResponse>";
        assert!(extract_fault(xml).is_some());
    }
}
