// Copyright 2026 California Institute of Technology
//
// You should have received a copy of the licensing terms for this
// software included in the file “LICENSE” located in the top-level
// directory of this package. If you did not, you can view a copy at
// http://dcc.ligo.org/M1500244/LICENSE.txt

//! End-to-end test of the acquisition backend: spawn the pcaspy test IOC
//! (tests/data/test_ioc.py) and drive `capture::Backend` in-process,
//! asserting on the shared store.  The TUI's interactive behavior is
//! unit-tested in `app.rs`; this covers the live EPICS data path.
//!
//! Requires python3 + pcaspy on PATH, so it is ignored by default; run it
//! with:
//!
//!     cargo test -p term_scope -- --ignored
//!
//! Everything is bound to loopback on a pid-derived port so the test never
//! talks to (or disturbs) a real control network.

use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use term_scope::capture::{Backend, SignalStore};
use term_scope::signal::{ChannelEntry, SignalData, SignalInfo};

fn data_path(name: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests/data")
        .join(name)
}

/// The pcaspy test IOC.  Its lifetime is controlled over stdin: it exits on
/// a "quit" line (or EOF); drop kills it as a backstop so a failing assert
/// can't leak the process.
struct Ioc {
    child: Child,
    stdin: ChildStdin,
}

impl Ioc {
    fn spawn(port: &str) -> Self {
        let mut child = Command::new("python3")
            .arg(data_path("test_ioc.py"))
            .env("EPICS_CAS_SERVER_PORT", port)
            .env("EPICS_CAS_INTF_ADDR_LIST", "127.0.0.1")
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()
            .expect("failed to start test_ioc.py; are python3 and pcaspy installed?");
        let stdin = child.stdin.take().expect("stdin is piped");
        // The IOC prints "ready" once its PVs are being served.
        let stdout = child.stdout.take().expect("stdout is piped");
        let mut greeting = String::new();
        BufReader::new(stdout)
            .read_line(&mut greeting)
            .expect("failed to read from test_ioc.py");
        assert_eq!(greeting.trim(), "ready", "unexpected IOC greeting");
        Self { child, stdin }
    }

    /// Ask the IOC to exit and wait until it has.
    fn stop(mut self) {
        let _ = writeln!(self.stdin, "quit");
        let _ = self.child.wait();
    }
}

impl Drop for Ioc {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

/// The current data for `name`, or None while it is disconnected.
fn value_of(map: &HashMap<String, ChannelEntry>, name: &str) -> Option<SignalData> {
    map.get(name).and_then(|entry| entry.value.data.clone())
}

/// The cached backend description for `name`, if any.
fn info_of(map: &HashMap<String, ChannelEntry>, name: &str) -> Option<SignalInfo> {
    map.get(name).and_then(|entry| entry.info.clone())
}

/// One value from a description, by key.
fn info_value(info: &SignalInfo, key: &str) -> Option<String> {
    info.0
        .iter()
        .find(|(k, _)| k == key)
        .map(|(_, v)| v.clone())
}

/// Poll the store until `pred` holds or `timeout` passes.
fn wait_for(
    store: &SignalStore,
    timeout: Duration,
    pred: impl Fn(&HashMap<String, ChannelEntry>) -> bool,
) -> bool {
    let deadline = Instant::now() + timeout;
    loop {
        if pred(&store.lock().unwrap()) {
            return true;
        }
        if Instant::now() >= deadline {
            return false;
        }
        std::thread::sleep(Duration::from_millis(100));
    }
}

#[test]
#[ignore = "requires python3 + pcaspy on PATH"]
fn live_ioc_round_trip() {
    // A pid-derived port keeps concurrent test runs from colliding.
    let port = (20000 + std::process::id() % 20000).to_string();

    // SAFETY: this is the file's only test and no other threads exist yet
    // (the IOC child and the backend are spawned below), so nothing reads
    // the environment concurrently.
    unsafe {
        std::env::set_var("EPICS_CA_SERVER_PORT", &port);
        std::env::set_var("EPICS_CA_ADDR_LIST", "127.0.0.1");
        std::env::set_var("EPICS_CA_AUTO_ADDR_LIST", "NO");
    }

    let ioc = Ioc::spawn(&port);

    let channels: Vec<String> = [
        "X1:COUNT",
        "X1:VOLTS",
        "X1:MODE",
        "X1:NAME",
        "X1:INTVAL",
        "X1:DOES_NOT_EXIST",
    ]
    .iter()
    .map(|name| name.to_string())
    .collect();
    let store: SignalStore = Arc::new(Mutex::new(HashMap::new()));
    let backend = Backend::spawn(channels, store.clone());

    // Live values arrive with the right types and formatting, enum labels
    // included.
    assert!(
        wait_for(&store, Duration::from_secs(20), |map| {
            value_of(map, "X1:VOLTS") == Some(SignalData::Float64(3.25))
                && value_of(map, "X1:MODE") == Some(SignalData::Enumeration("1, ON".to_string()))
                && value_of(map, "X1:NAME") == Some(SignalData::String("hello world".to_string()))
                && value_of(map, "X1:INTVAL") == Some(SignalData::Int32(42))
        }),
        "expected values never appeared; store: {:?}",
        store.lock().unwrap()
    );

    // Connected channels carry a pvinfo-style description with a real host
    // and their native type; the never-connected one has no description.
    {
        let map = store.lock().unwrap();
        let info = info_of(&map, "X1:VOLTS").expect("X1:VOLTS should have info");
        let host = info_value(&info, "host").expect("info should carry a host");
        assert!(
            host.contains("127.0.0.1") || host.contains("localhost"),
            "unexpected host {host}"
        );
        assert_eq!(
            info_value(&info, "native type").as_deref(),
            Some("DBF_DOUBLE")
        );
        assert_eq!(
            info_value(&info, "request type").as_deref(),
            Some("DBR_TIME_DOUBLE")
        );
        assert_eq!(info_value(&info, "element count").as_deref(), Some("1"));
        assert!(info_value(&info, "access").is_some());
        assert_eq!(info_of(&map, "X1:DOES_NOT_EXIST"), None);
    }

    // The never-connected channel is present but has no data.
    assert_eq!(value_of(&store.lock().unwrap(), "X1:DOES_NOT_EXIST"), None);

    // The counter is alive: it takes two distinct values.
    let first = value_of(&store.lock().unwrap(), "X1:COUNT");
    assert!(
        wait_for(&store, Duration::from_secs(10), |map| {
            let count = value_of(map, "X1:COUNT");
            count.is_some() && count != first
        }),
        "X1:COUNT never changed"
    );

    // Stop the IOC: values must blank out (no stale values), but the cached
    // description must survive the disconnect.
    ioc.stop();
    assert!(
        wait_for(&store, Duration::from_secs(15), |map| {
            value_of(map, "X1:VOLTS").is_none()
        }),
        "values did not blank after the IOC stopped"
    );
    assert!(
        info_of(&store.lock().unwrap(), "X1:VOLTS").is_some(),
        "info must stay cached through a disconnect"
    );

    // Restart the IOC: values must come back.
    let _ioc2 = Ioc::spawn(&port);
    assert!(
        wait_for(&store, Duration::from_secs(60), |map| {
            value_of(map, "X1:VOLTS") == Some(SignalData::Float64(3.25))
        }),
        "values did not recover after the IOC restarted"
    );

    // The backend shuts down cleanly (the thread joins).
    backend.shutdown();
}
