//! Dynamic PLC testing via an ephemeral soft-PLC (#183).
//!
//! When a PLC/SPS target ships control logic but no reachable live device, the
//! agent instantiates that logic itself instead of trying to reach the customer's
//! OT network: it provisions a throwaway soft-PLC (OpenPLC) container in-cluster,
//! loads the program, starts the runtime, probes it over industrial protocols,
//! then tears the instance down. No customer network access, sandboxed, and
//! reproducible — destructive tests become safe because the target is ours.
//!
//! - [`provision`] owns the container lifecycle (sub-task 1 + 5).
//! - [`openplc`] loads the program into the running instance (sub-task 2).
//! - [`provision_and_test`] composes them with a hard deadline and guaranteed
//!   teardown, and runs the ICS probe against the provisioned endpoint.

pub mod openplc;
pub mod provision;

use std::path::Path;
use std::time::Duration;

use secrecy::ExposeSecret;

use compliance_core::models::dast::{DastFinding, DastScanRun, DastTarget, DastTargetType};
use compliance_core::models::Finding;
use compliance_core::PlcRuntimeConfig;

use crate::error::ExecError;

pub use provision::{DockerSoftPlc, ProvisionedRuntime, SoftPlc};

/// The result of a DAST scan against a provisioned web endpoint.
#[derive(Debug)]
pub struct DastRunResult {
    /// The scan-run record (linked to the onboarded target).
    pub scan_run: DastScanRun,
    /// The DAST findings.
    pub findings: Vec<DastFinding>,
}

/// Everything a provision-and-test run produced: the ICS-probe findings plus, if
/// it ran, the DAST scan of the provisioned web endpoint. The caller persists
/// both — keeping this a plain data return means the whole run is portable to a
/// remote execution backend that just hands the results back.
#[derive(Debug, Default)]
pub struct ProvisionOutcome {
    /// ICS-probe findings from the provisioned Modbus endpoint.
    pub findings: Vec<Finding>,
    /// DAST scan of the provisioned web endpoint, if it ran.
    pub dast: Option<DastRunResult>,
}

/// A control-logic program ready to load into a soft-PLC: the source text plus a
/// cosmetic file name (OpenPLC re-stores it under its own name).
#[derive(Debug, Clone)]
pub struct PlcProgram {
    /// The original file name (for the upload form; OpenPLC renames on storage).
    pub file_name: String,
    /// The program source — Structured Text or PLCopen XML.
    pub source: String,
}

/// A cookie-aware HTTP client for the OpenPLC web UI. A fresh client per scan
/// isolates the OpenPLC session (its Flask login cookie) from every other scan.
pub fn http_client() -> Result<reqwest::Client, ExecError> {
    reqwest::Client::builder()
        .cookie_store(true)
        .timeout(Duration::from_secs(30))
        .build()
        .map_err(ExecError::Http)
}

/// Pick the control-logic program to run from an ingested PLC source tree.
///
/// OpenPLC runs one program, so we choose the best single candidate: a complete
/// Structured Text program (one carrying a `CONFIGURATION` block) is ideal;
/// failing that the largest ST file; failing that a PLCopen XML export. Returns
/// `None` when the tree holds no loadable control logic.
pub fn extract_program(root: &Path) -> Option<PlcProgram> {
    let mut st: Vec<(String, String)> = Vec::new();
    let mut xml: Vec<(String, String)> = Vec::new();
    for entry in walkdir::WalkDir::new(root)
        .into_iter()
        .filter_map(Result::ok)
    {
        if !entry.file_type().is_file() {
            continue;
        }
        let path = entry.path();
        let ext = path
            .extension()
            .and_then(|e| e.to_str())
            .unwrap_or("")
            .to_ascii_lowercase();
        let is_st = matches!(ext.as_str(), "st" | "iecst" | "scl" | "exp" | "il");
        let is_xml = matches!(ext.as_str(), "xml" | "plcopen" | "project");
        if !is_st && !is_xml {
            continue;
        }
        let Ok(content) = std::fs::read_to_string(path) else {
            continue;
        };
        let name = path
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or("program")
            .to_string();
        if is_st {
            st.push((name, content));
        } else if looks_like_plcopen(&content) {
            xml.push((name, content));
        }
    }

    if let Some((name, source)) = st.iter().find(|(_, c)| has_configuration(c)) {
        return Some(PlcProgram {
            file_name: name.clone(),
            source: source.clone(),
        });
    }
    if let Some((name, source)) = st.iter().max_by_key(|(_, c)| c.len()) {
        return Some(PlcProgram {
            file_name: name.clone(),
            source: source.clone(),
        });
    }
    xml.into_iter()
        .max_by_key(|(_, c)| c.len())
        .map(|(file_name, source)| PlcProgram { file_name, source })
}

/// Whether an ST source is a complete, runnable program (has a `CONFIGURATION`).
fn has_configuration(source: &str) -> bool {
    source.to_ascii_uppercase().contains("CONFIGURATION")
}

/// Whether an XML file looks like a PLCopen project export.
fn looks_like_plcopen(source: &str) -> bool {
    let lower = source.to_ascii_lowercase();
    lower.contains("<project") || lower.contains("plcopen")
}

/// Provision an ephemeral soft-PLC, load `program`, start it, probe it over
/// industrial protocols, and tear it down. Returns the ICS-probe findings.
///
/// Teardown is guaranteed: the load/probe work runs under a hard deadline
/// (`max_lifetime_secs`) and the instance is removed afterwards on every path —
/// success, error, or deadline expiry.
pub async fn provision_and_test<P: SoftPlc>(
    provisioner: &P,
    http: &reqwest::Client,
    cfg: &PlcRuntimeConfig,
    program: &PlcProgram,
    target_id: &str,
) -> Result<ProvisionOutcome, ExecError> {
    let handle = provisioner.provision(target_id).await?;
    tracing::info!(
        target_id,
        instance = %handle.name,
        modbus = %handle.modbus_endpoint,
        "provisioned ephemeral soft-PLC"
    );

    let deadline = Duration::from_secs(cfg.max_lifetime_secs);
    let result = tokio::time::timeout(
        deadline,
        run_dynamic_test(http, cfg, program, target_id, &handle),
    )
    .await;

    // Guaranteed teardown — runs on success, error, and deadline expiry. The
    // inner future is panic-free (the workspace lint bans unwrap/expect), so no
    // unwind can skip this; a container leaked by an agent *crash* is swept by
    // the next run's stale reaper.
    provisioner.teardown(&handle).await;

    match result {
        Ok(inner) => inner,
        Err(_) => {
            tracing::warn!(
                target_id,
                instance = %handle.name,
                "provision-and-test hit the lifetime deadline; torn down"
            );
            Ok(ProvisionOutcome::default())
        }
    }
}

/// The load → start → probe → DAST body, run under the caller's deadline.
async fn run_dynamic_test(
    http: &reqwest::Client,
    cfg: &PlcRuntimeConfig,
    program: &PlcProgram,
    target_id: &str,
    handle: &ProvisionedRuntime,
) -> Result<ProvisionOutcome, ExecError> {
    let ready_budget = Duration::from_secs((cfg.max_lifetime_secs / 3).clamp(10, 60));
    openplc::wait_ready(http, &handle.webvisu_url, ready_budget).await?;

    let compile_budget = Duration::from_secs((cfg.max_lifetime_secs / 2).clamp(20, 120));
    openplc::load_and_start(
        http,
        &handle.webvisu_url,
        &cfg.openplc_user,
        cfg.openplc_password.expose_secret(),
        program,
        compile_budget,
    )
    .await?;

    // Give the runtime a moment to open the Modbus/TCP server before probing.
    tokio::time::sleep(Duration::from_secs(3)).await;

    let probe_budget = Duration::from_secs(5);
    let findings = crate::ics::probe_target(&handle.modbus_endpoint, target_id, probe_budget).await;
    tracing::info!(
        target_id,
        instance = %handle.name,
        found = findings.len(),
        "provision-and-test probe complete"
    );

    // DAST the provisioned web endpoint (independently bounded so it can't eat
    // the whole lifetime). On the OpenPLC substrate this is OpenPLC's own web UI,
    // not a customer HMI — the CODESYS-runtime follow-up raises the fidelity —
    // but it proves the deploy→run→probe→DAST loop end to end.
    let dast_budget = Duration::from_secs((cfg.max_lifetime_secs / 2).clamp(20, 120));
    let dast = match tokio::time::timeout(dast_budget, run_webvisu_dast(handle, target_id)).await {
        Ok(d) => d,
        Err(_) => {
            tracing::warn!(target_id, instance = %handle.name, "provision-and-test DAST timed out");
            None
        }
    };

    Ok(ProvisionOutcome { findings, dast })
}

/// Run a bounded DAST scan against the provisioned web endpoint and tag the
/// results with our target id. Best-effort — a DAST failure never fails the run.
async fn run_webvisu_dast(handle: &ProvisionedRuntime, target_id: &str) -> Option<DastRunResult> {
    let mut dt = DastTarget::new(
        "provisioned-webvisu".to_string(),
        handle.webvisu_url.clone(),
        DastTargetType::WebApp,
    );
    dt.repo_id = Some(target_id.to_string());
    dt.max_crawl_depth = 2; // shallow — the instance is ephemeral

    let orchestrator = compliance_dast::DastOrchestrator::new(100);
    match orchestrator.run_scan(&dt, Vec::new()).await {
        Ok((mut scan_run, mut findings)) => {
            scan_run.target_id = target_id.to_string();
            for f in &mut findings {
                f.target_id = target_id.to_string();
            }
            tracing::info!(
                target_id,
                instance = %handle.name,
                dast_findings = findings.len(),
                "provision-and-test DAST complete"
            );
            Some(DastRunResult { scan_run, findings })
        }
        Err(e) => {
            tracing::warn!(target_id, instance = %handle.name, error = %e, "provision-and-test DAST failed");
            None
        }
    }
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::sync::Arc;

    /// A scratch dir removed on drop.
    struct Scratch(std::path::PathBuf);
    impl Scratch {
        fn new() -> Self {
            let p = std::env::temp_dir().join(format!("cs-plc-rt-{}", uuid::Uuid::new_v4()));
            std::fs::create_dir_all(&p).expect("mkdir");
            Self(p)
        }
    }
    impl Drop for Scratch {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.0);
        }
    }

    #[test]
    fn extract_prefers_a_complete_st_program() {
        let s = Scratch::new();
        std::fs::write(s.0.join("fragment.st"), "PROGRAM P\nEND_PROGRAM\n").expect("w");
        std::fs::write(
            s.0.join("full.st"),
            "PROGRAM Main\nEND_PROGRAM\nCONFIGURATION Config0\n  RESOURCE R\nEND_CONFIGURATION\n",
        )
        .expect("w");
        let prog = extract_program(&s.0).expect("program");
        assert_eq!(prog.file_name, "full.st");
        assert!(prog.source.contains("CONFIGURATION"));
    }

    #[test]
    fn extract_falls_back_to_largest_st_then_plcopen() {
        let s = Scratch::new();
        std::fs::write(s.0.join("small.st"), "PROGRAM A\nEND_PROGRAM\n").expect("w");
        std::fs::write(
            s.0.join("big.st"),
            "PROGRAM B\nVAR x : INT; y : INT; z : INT; END_VAR\nEND_PROGRAM\n",
        )
        .expect("w");
        let prog = extract_program(&s.0).expect("program");
        assert_eq!(
            prog.file_name, "big.st",
            "largest ST wins when none complete"
        );

        // Only a PLCopen XML present.
        let s2 = Scratch::new();
        std::fs::write(
            s2.0.join("proj.xml"),
            "<?xml version='1.0'?><project xmlns='http://www.plcopen.org/xml/tc6_0201'><pou/></project>",
        )
        .expect("w");
        let prog2 = extract_program(&s2.0).expect("program");
        assert_eq!(prog2.file_name, "proj.xml");
    }

    #[test]
    fn extract_returns_none_without_control_logic() {
        let s = Scratch::new();
        std::fs::write(s.0.join("readme.md"), "# not a plc program").expect("w");
        std::fs::write(s.0.join("data.xml"), "<config><db/></config>").expect("w");
        assert!(extract_program(&s.0).is_none());
    }

    /// A fake provisioner recording provision/teardown calls, for lifecycle tests.
    struct FakeSoftPlc {
        provisions: Arc<AtomicUsize>,
        teardowns: Arc<AtomicUsize>,
        fail_provision: bool,
    }

    impl SoftPlc for FakeSoftPlc {
        async fn provision(&self, _target_id: &str) -> Result<ProvisionedRuntime, ExecError> {
            self.provisions.fetch_add(1, Ordering::SeqCst);
            if self.fail_provision {
                return Err(ExecError::Other("provision failed".into()));
            }
            // Unreachable address so run_dynamic_test blocks on readiness until the
            // deadline fires — exercising the teardown-on-deadline path.
            Ok(ProvisionedRuntime {
                name: "fake-plc".into(),
                modbus_endpoint: "fake-plc:502".into(),
                webvisu_url: "http://fake-plc.invalid:8080".into(),
            })
        }
        async fn teardown(&self, _handle: &ProvisionedRuntime) {
            self.teardowns.fetch_add(1, Ordering::SeqCst);
        }
    }

    fn short_cfg() -> PlcRuntimeConfig {
        PlcRuntimeConfig {
            enabled: true,
            max_lifetime_secs: 1, // keep the deadline path fast
            ..PlcRuntimeConfig::default()
        }
    }

    #[tokio::test]
    async fn teardown_runs_even_when_the_test_never_completes() {
        let provisions = Arc::new(AtomicUsize::new(0));
        let teardowns = Arc::new(AtomicUsize::new(0));
        let fake = FakeSoftPlc {
            provisions: provisions.clone(),
            teardowns: teardowns.clone(),
            fail_provision: false,
        };
        let http = http_client().expect("client");
        let prog = PlcProgram {
            file_name: "p.st".into(),
            source: "PROGRAM P\nEND_PROGRAM\n".into(),
        };
        let out = provision_and_test(&fake, &http, &short_cfg(), &prog, "t1")
            .await
            .expect("ok on deadline");
        assert!(out.findings.is_empty(), "deadline path yields no findings");
        assert!(out.dast.is_none(), "deadline path runs no DAST");
        assert_eq!(provisions.load(Ordering::SeqCst), 1);
        assert_eq!(teardowns.load(Ordering::SeqCst), 1, "teardown must run");
    }

    #[tokio::test]
    async fn provision_failure_propagates_and_skips_teardown() {
        let provisions = Arc::new(AtomicUsize::new(0));
        let teardowns = Arc::new(AtomicUsize::new(0));
        let fake = FakeSoftPlc {
            provisions: provisions.clone(),
            teardowns: teardowns.clone(),
            fail_provision: true,
        };
        let http = http_client().expect("client");
        let prog = PlcProgram {
            file_name: "p.st".into(),
            source: String::new(),
        };
        let err = provision_and_test(&fake, &http, &short_cfg(), &prog, "t1").await;
        assert!(err.is_err(), "provision failure propagates");
        assert_eq!(provisions.load(Ordering::SeqCst), 1);
        assert_eq!(
            teardowns.load(Ordering::SeqCst),
            0,
            "nothing to tear down when provisioning failed"
        );
    }
}
