//! Ephemeral soft-PLC container lifecycle (#183, sub-task 1 + 5).
//!
//! Provisions a throwaway OpenPLC container per scan, isolated on the agent's own
//! Docker network with hard resource caps and **no host port exposure**, then
//! guarantees teardown. The container is reachable in-cluster only, by its name
//! (the shared user-defined network's embedded DNS resolves it); it is never
//! published to the host.
//!
//! The `docker` argv is produced by pure functions so provisioning is unit-tested
//! without a Docker daemon — only the thin [`run_docker`] wrapper touches the OS.
//! It requires the agent's runtime to have Docker access (a socket mount), which
//! is why the whole path is gated behind [`PlcRuntimeConfig::enabled`].

use std::time::{SystemTime, UNIX_EPOCH};

use compliance_core::PlcRuntimeConfig;

use crate::error::ExecError;

/// The Modbus/TCP port an OpenPLC instance opens once a program is running.
const MODBUS_PORT: u16 = 502;
/// The OpenPLC web-UI / WebVisu port.
const WEBVISU_PORT: u16 = 8080;

/// Label key marking a container as an ephemeral PLC runtime we own.
const OWNER_LABEL_KEY: &str = "certifai.ephemeral";
/// Label value for our ephemeral PLC runtimes.
const OWNER_LABEL_VALUE: &str = "plc-runtime";

/// A running ephemeral soft-PLC instance. Reachable in-cluster by `name`.
#[derive(Debug, Clone)]
pub struct ProvisionedRuntime {
    /// The container name — also its in-network DNS alias.
    pub name: String,
    /// `name:502` — the Modbus/TCP endpoint the ICS probe targets.
    pub modbus_endpoint: String,
    /// `http://name:8080` — the WebVisu / OpenPLC web UI.
    pub webvisu_url: String,
}

/// A source of ephemeral soft-PLC instances. Abstracted so the provision-and-test
/// orchestration is unit-testable with a fake that never touches Docker.
pub trait SoftPlc {
    /// Start a fresh instance for a target and return its handle.
    fn provision(
        &self,
        target_id: &str,
    ) -> impl std::future::Future<Output = Result<ProvisionedRuntime, ExecError>> + Send;

    /// Tear an instance down. Best-effort and idempotent — never fails the scan.
    fn teardown(&self, handle: &ProvisionedRuntime)
        -> impl std::future::Future<Output = ()> + Send;
}

/// Provisions OpenPLC instances by shelling out to the Docker CLI.
pub struct DockerSoftPlc {
    cfg: PlcRuntimeConfig,
}

impl DockerSoftPlc {
    /// Build a provisioner from the PLC-runtime config.
    pub fn new(cfg: PlcRuntimeConfig) -> Self {
        Self { cfg }
    }
}

impl SoftPlc for DockerSoftPlc {
    async fn provision(&self, target_id: &str) -> Result<ProvisionedRuntime, ExecError> {
        // Best-effort sweep of any container leaked by a crashed earlier run
        // before we add another. Only removes instances past their max lifetime,
        // so it can never disturb a concurrent run.
        reap_stale(&self.cfg, now_epoch()).await;

        let name = instance_name(target_id, now_epoch(), &random_suffix());
        let args = run_args(&self.cfg, &name, target_id);
        let out = run_docker(&args).await?;
        if !out.status.success() {
            return Err(ExecError::Other(format!(
                "docker run for soft-PLC {name} failed: {}",
                String::from_utf8_lossy(&out.stderr).trim()
            )));
        }
        Ok(ProvisionedRuntime {
            modbus_endpoint: format!("{name}:{MODBUS_PORT}"),
            webvisu_url: format!("http://{name}:{WEBVISU_PORT}"),
            name,
        })
    }

    async fn teardown(&self, handle: &ProvisionedRuntime) {
        match run_docker(&rm_args(&handle.name)).await {
            Ok(out) if out.status.success() => {
                tracing::info!(instance = %handle.name, "soft-PLC instance torn down");
            }
            Ok(out) => tracing::warn!(
                instance = %handle.name,
                "soft-PLC teardown non-zero exit: {}",
                String::from_utf8_lossy(&out.stderr).trim()
            ),
            Err(e) => {
                tracing::warn!(instance = %handle.name, error = %e, "soft-PLC teardown failed")
            }
        }
    }
}

/// Seconds since the Unix epoch (0 if the clock is before 1970, which never
/// happens in practice).
fn now_epoch() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

/// A short random, docker-name-safe suffix.
fn random_suffix() -> String {
    uuid::Uuid::new_v4().simple().to_string()
}

/// A unique, docker-safe container name that encodes the creation epoch (for the
/// stale reaper) and the target it belongs to. Shape:
/// `certifai-plc-<epoch>-<target12>-<rand6>`.
fn instance_name(target_id: &str, epoch: u64, rand: &str) -> String {
    let short: String = target_id
        .chars()
        .filter(char::is_ascii_alphanumeric)
        .take(12)
        .collect();
    let rand: String = rand
        .chars()
        .filter(char::is_ascii_alphanumeric)
        .take(6)
        .collect();
    format!("certifai-plc-{epoch}-{short}-{rand}")
}

/// The creation epoch encoded in an instance name, if it is one of ours.
fn parse_epoch(name: &str) -> Option<u64> {
    name.strip_prefix("certifai-plc-")?
        .split('-')
        .next()?
        .parse()
        .ok()
}

/// The `docker run` argv for an ephemeral soft-PLC: detached, joined to the
/// agent's network, resource-capped, hardened, labelled for reaping, and — by
/// omitting any `-p` — never published to the host.
fn run_args(cfg: &PlcRuntimeConfig, name: &str, target_id: &str) -> Vec<String> {
    vec![
        "run".into(),
        "-d".into(),
        "--name".into(),
        name.into(),
        "--network".into(),
        cfg.network.clone(),
        "--memory".into(),
        cfg.memory.clone(),
        "--cpus".into(),
        cfg.cpus.clone(),
        "--pids-limit".into(),
        "512".into(),
        "--security-opt".into(),
        "no-new-privileges".into(),
        "--stop-timeout".into(),
        "5".into(),
        "--label".into(),
        format!("{OWNER_LABEL_KEY}={OWNER_LABEL_VALUE}"),
        "--label".into(),
        format!("certifai.target={target_id}"),
        cfg.image.clone(),
    ]
}

/// The `docker rm -f` argv that stops and removes an instance.
fn rm_args(name: &str) -> Vec<String> {
    vec!["rm".into(), "-f".into(), name.into()]
}

/// The `docker ps` argv listing the names of every ephemeral PLC container we own.
fn reap_list_args() -> Vec<String> {
    vec![
        "ps".into(),
        "-a".into(),
        "--filter".into(),
        format!("label={OWNER_LABEL_KEY}={OWNER_LABEL_VALUE}"),
        "--format".into(),
        "{{.Names}}".into(),
    ]
}

/// Remove any ephemeral PLC container older than twice the configured max
/// lifetime — i.e. one a crashed run leaked. The generous threshold guarantees a
/// container from a *live* run (still within its own deadline) is never swept.
/// Best-effort: any Docker error (e.g. no daemon) is ignored.
async fn reap_stale(cfg: &PlcRuntimeConfig, now: u64) {
    let cutoff = cfg.max_lifetime_secs.saturating_mul(2);
    let Ok(out) = run_docker(&reap_list_args()).await else {
        return;
    };
    if !out.status.success() {
        return;
    }
    let names = String::from_utf8_lossy(&out.stdout);
    for name in names.lines().map(str::trim).filter(|n| !n.is_empty()) {
        let Some(epoch) = parse_epoch(name) else {
            continue;
        };
        if now.saturating_sub(epoch) > cutoff {
            tracing::warn!(instance = %name, "reaping stale soft-PLC instance");
            let _ = run_docker(&rm_args(name)).await;
        }
    }
}

/// Run a `docker` subcommand, capturing its output.
async fn run_docker(args: &[String]) -> Result<std::process::Output, ExecError> {
    tokio::process::Command::new("docker")
        .args(args)
        .output()
        .await
        .map_err(ExecError::Io)
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
    use super::*;

    fn cfg() -> PlcRuntimeConfig {
        PlcRuntimeConfig {
            enabled: true,
            image: "registry.example.com/openplc:latest".into(),
            network: "certifai".into(),
            memory: "512m".into(),
            cpus: "0.5".into(),
            max_lifetime_secs: 180,
            ..PlcRuntimeConfig::default()
        }
    }

    #[test]
    fn instance_name_is_unique_docker_safe_and_reaper_parseable() {
        let a = instance_name("64f0aabbccddeeff00112233", 1_700_000_000, "abcdef123456");
        assert_eq!(a, "certifai-plc-1700000000-64f0aabbccdd-abcdef");
        assert_eq!(parse_epoch(&a), Some(1_700_000_000));
        // Docker names: only [A-Za-z0-9_.-].
        assert!(a
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')));
        // A different random suffix yields a different name for the same target.
        let b = instance_name("64f0aabbccddeeff00112233", 1_700_000_000, "zzzzzz999999");
        assert_ne!(a, b);
    }

    #[test]
    fn parse_epoch_rejects_foreign_names() {
        assert_eq!(parse_epoch("some-other-container"), None);
        assert_eq!(parse_epoch("certifai-plc-notanumber-x"), None);
    }

    #[test]
    fn run_args_cap_resources_harden_label_and_never_publish_a_port() {
        let args = run_args(&cfg(), "certifai-plc-1-t-r", "target-123");
        // No host port publishing.
        assert!(!args.iter().any(|a| a == "-p" || a == "--publish"));
        // Detached.
        assert!(args.contains(&"-d".to_string()));
        // Joined to the agent's own network.
        let net = args.iter().position(|a| a == "--network").expect("network");
        assert_eq!(args[net + 1], "certifai");
        // Resource caps.
        let mem = args.iter().position(|a| a == "--memory").expect("memory");
        assert_eq!(args[mem + 1], "512m");
        let cpu = args.iter().position(|a| a == "--cpus").expect("cpus");
        assert_eq!(args[cpu + 1], "0.5");
        assert!(args.iter().any(|a| a == "--pids-limit"));
        // Hardening.
        let so = args
            .iter()
            .position(|a| a == "--security-opt")
            .expect("secopt");
        assert_eq!(args[so + 1], "no-new-privileges");
        // Ownership + target labels for reaping / attribution.
        assert!(args.contains(&"certifai.ephemeral=plc-runtime".to_string()));
        assert!(args.contains(&"certifai.target=target-123".to_string()));
        // Image is last.
        assert_eq!(
            args.last().map(String::as_str),
            Some("registry.example.com/openplc:latest")
        );
    }

    #[test]
    fn rm_args_force_remove() {
        assert_eq!(rm_args("x"), vec!["rm", "-f", "x"]);
    }

    #[test]
    fn reap_list_filters_by_owner_label() {
        let args = reap_list_args();
        assert!(args.contains(&"label=certifai.ephemeral=plc-runtime".to_string()));
        assert!(args.contains(&"{{.Names}}".to_string()));
    }
}
