//! Dynamic ICS (industrial control system) probing for PLC/SPS targets.
//!
//! Where the control-logic scanner is static (over ST / PLCopen XML), this probes
//! the *running* device over industrial protocols and reports exposed /
//! unauthenticated control interfaces. It is read-only: it never writes to a live
//! process. Modbus/TCP and OPC UA are implemented; EtherNet-IP is a follow-on.

pub mod ethernetip;
pub mod modbus;
pub mod opcua;
pub mod portscan;

use std::time::Duration;

use compliance_core::models::{Finding, ScanType, Severity};

use crate::fingerprint as dedup;

/// Well-known deep-probe ports (each independent of any WebVisu HTTP port).
const MODBUS_PORT: u16 = 502;
const OPCUA_PORT: u16 = 4840;
const ENIP_PORT: u16 = 44818;

/// Probe a PLC/SPS device's industrial-protocol surface and return findings.
/// Read-only. Deep-probes Modbus/TCP, OPC UA and EtherNet/IP, plus a service
/// discovery scan of the remaining OT / insecure-management ports. `endpoint` is
/// the target's live-URL / host reference.
pub async fn probe_target(endpoint: &str, repo_id: &str, budget: Duration) -> Vec<Finding> {
    let (host, modbus_port) = parse_endpoint(endpoint);
    let mut findings = modbus_findings(&host, modbus_port, repo_id, budget).await;
    findings.extend(opcua_findings(&host, OPCUA_PORT, repo_id, budget).await);
    findings.extend(enip_findings(&host, ENIP_PORT, repo_id, budget).await);
    findings.extend(portscan_findings(&host, repo_id, budget).await);
    findings
}

/// Findings from probing the Modbus/TCP surface.
async fn modbus_findings(host: &str, port: u16, repo_id: &str, budget: Duration) -> Vec<Finding> {
    let probe = modbus::probe(host, port, budget).await;
    let mut findings = Vec::new();
    if !probe.speaks_modbus {
        // Not reachable, or the port does not speak Modbus — nothing to report.
        return findings;
    }
    let target = format!("{host}:{port}");

    // Reachable Modbus/TCP = unauthenticated, cleartext control access by design.
    let fp = dedup::compute_fingerprint(&[repo_id, "ics-modbus-exposed", &target]);
    let mut f = Finding::new(
        repo_id.to_string(),
        fp,
        "ics-probe".to_string(),
        ScanType::IcsProbe,
        "Modbus/TCP control interface exposed without authentication".to_string(),
        format!(
            "The device at {target} answers Modbus/TCP requests. Modbus/TCP has no \
             authentication or encryption in the protocol, so any host that can reach this \
             port can read and write process variables (coils/registers) and disrupt the \
             controlled process."
        ),
        Severity::Critical,
    );
    f.rule_id = Some("ics-modbus-exposed".to_string());
    f.cwe = Some("CWE-306".to_string());
    f.remediation = Some(
        "Restrict the Modbus/TCP port to a trusted control network (segmentation / \
         firewall / VPN), never expose it to IT or the internet, and prefer an authenticated \
         transport (e.g. Modbus/TLS) or a secure protocol gateway where available."
            .to_string(),
    );
    findings.push(f);

    if let Some(dev) = &probe.device {
        let details = [
            dev.vendor.as_deref(),
            dev.product.as_deref(),
            dev.revision.as_deref(),
        ]
        .into_iter()
        .flatten()
        .collect::<Vec<_>>()
        .join(" / ");
        let fp = dedup::compute_fingerprint(&[repo_id, "ics-device-disclosure", &target]);
        let mut f = Finding::new(
            repo_id.to_string(),
            fp,
            "ics-probe".to_string(),
            ScanType::IcsProbe,
            "PLC device identity disclosed over Modbus".to_string(),
            format!(
                "The device at {target} discloses its identity via Modbus Read Device \
                 Identification: {details}. This aids fingerprinting and targeting of \
                 known-vulnerable firmware/runtime versions."
            ),
            Severity::Low,
        );
        f.rule_id = Some("ics-device-disclosure".to_string());
        f.cwe = Some("CWE-200".to_string());
        f.remediation = Some(
            "Limit network reach to the device; Modbus device identification cannot be \
             disabled, so exposure is bounded by network segmentation."
                .to_string(),
        );
        findings.push(f);
    }

    // Exposed process points: coils / holding registers that a read enumerated
    // and that, over unauthenticated Modbus/TCP, are also writable. This is the
    // concrete attack surface behind the exposure — the live variables an
    // attacker can overwrite. (Read-only to detect: we never write.)
    let coils = probe.coils_readable.unwrap_or(0);
    let registers = probe.holding_registers_readable.unwrap_or(0);
    if coils > 0 || registers > 0 {
        let fp = dedup::compute_fingerprint(&[repo_id, "ics-modbus-exposed-points", &target]);
        let mut f = Finding::new(
            repo_id.to_string(),
            fp,
            "ics-probe".to_string(),
            ScanType::IcsProbe,
            "Writable process points exposed over unauthenticated Modbus/TCP".to_string(),
            format!(
                "Reading the device at {target} enumerated {coils} coil(s) and {registers} \
                 holding register(s). Coils and holding registers are read/write process points \
                 in Modbus, so any host that can reach this port can not only read but overwrite \
                 live process state (force coils, change setpoints) without authentication."
            ),
            Severity::High,
        );
        f.rule_id = Some("ics-modbus-exposed-points".to_string());
        f.cwe = Some("CWE-306".to_string());
        f.remediation = Some(
            "Segment the Modbus/TCP port to a trusted control network; where the device \
             supports it use Modbus/TLS or an authenticating protocol gateway; restrict which \
             function codes and register ranges are reachable from outside the control zone."
                .to_string(),
        );
        findings.push(f);
    }
    findings
}

/// Findings from probing the OPC UA surface (default port 4840). A reachability
/// probe only: it flags an exposed OPC UA server for review of its security
/// policy / authentication (deep SecurityPolicy analysis is a follow-on).
async fn opcua_findings(host: &str, port: u16, repo_id: &str, budget: Duration) -> Vec<Finding> {
    let probe = opcua::probe(host, port, budget).await;
    let mut findings = Vec::new();
    if !probe.is_opcua {
        return findings;
    }
    let target = format!("{host}:{port}");
    let fp = dedup::compute_fingerprint(&[repo_id, "ics-opcua-exposed", &target]);
    let mut f = Finding::new(
        repo_id.to_string(),
        fp,
        "ics-probe".to_string(),
        ScanType::IcsProbe,
        "OPC UA server exposed on the network".to_string(),
        format!(
            "An OPC UA server answers at {target}. Verify it enforces message security \
             (a SecurityPolicy other than None) and rejects anonymous sessions — the common \
             default of SecurityPolicy None + an Anonymous user token allows unauthenticated, \
             unencrypted read/write of the server's address space."
        ),
        Severity::Medium,
    );
    f.rule_id = Some("ics-opcua-exposed".to_string());
    f.cwe = Some("CWE-319".to_string());
    f.remediation = Some(
        "Restrict OPC UA (4840) to a trusted network; require a signed & encrypted \
         SecurityPolicy (Basic256Sha256 or better) with certificate / username \
         authentication, and disable the Anonymous user token."
            .to_string(),
    );
    findings.push(f);
    findings
}

/// Findings from probing the EtherNet/IP (CIP) surface (default port 44818).
async fn enip_findings(host: &str, port: u16, repo_id: &str, budget: Duration) -> Vec<Finding> {
    let probe = ethernetip::probe(host, port, budget).await;
    if !probe.is_enip {
        return Vec::new();
    }
    let target = format!("{host}:{port}");
    let fp = dedup::compute_fingerprint(&[repo_id, "ics-ethernetip-exposed", &target]);
    let mut f = Finding::new(
        repo_id.to_string(),
        fp,
        "ics-probe".to_string(),
        ScanType::IcsProbe,
        "EtherNet/IP (CIP) interface exposed on the network".to_string(),
        format!(
            "The device at {target} answers EtherNet/IP (CIP) requests. EtherNet/IP has no \
             authentication in the base protocol, so a host that can reach it can enumerate \
             and interact with the device's control objects."
        ),
        Severity::High,
    );
    f.rule_id = Some("ics-ethernetip-exposed".to_string());
    f.cwe = Some("CWE-306".to_string());
    f.remediation = Some(
        "Restrict EtherNet/IP (44818/2222) to a trusted control network; use CIP Security \
         (encryption + authentication) on devices that support it."
            .to_string(),
    );
    vec![f]
}

/// Findings from the service-discovery port scan of the remaining OT /
/// insecure-management surface.
async fn portscan_findings(host: &str, repo_id: &str, budget: Duration) -> Vec<Finding> {
    let open = portscan::scan(host, portscan::KNOWN_PORTS, budget).await;
    open.into_iter()
        .map(|kp| {
            let target = format!("{host}:{}", kp.port);
            let (title, severity, cwe, description) = match kp.kind {
                portscan::PortKind::Ics => (
                    format!("ICS service exposed: {}", kp.service),
                    Severity::High,
                    "CWE-306",
                    format!(
                        "{target} exposes {} ({}). Industrial protocols are typically \
                         unauthenticated, so network reach implies control access.",
                        kp.service, kp.note
                    ),
                ),
                portscan::PortKind::InsecureMgmt => (
                    format!("Cleartext service exposed: {}", kp.service),
                    Severity::Medium,
                    "CWE-319",
                    format!(
                        "{target} exposes {} ({}), which transmits credentials and data in \
                         cleartext.",
                        kp.service, kp.note
                    ),
                ),
            };
            let fp = dedup::compute_fingerprint(&[repo_id, "ics-service-exposed", &target]);
            let mut f = Finding::new(
                repo_id.to_string(),
                fp,
                "ics-probe".to_string(),
                ScanType::IcsProbe,
                title,
                description,
                severity,
            );
            f.rule_id = Some("ics-service-exposed".to_string());
            f.cwe = Some(cwe.to_string());
            f.remediation = Some(
                "Restrict the service to a trusted network segment; disable it if unused; \
                 replace cleartext protocols (Telnet/FTP) with SSH/SFTP."
                    .to_string(),
            );
            f
        })
        .collect()
}

/// Extract `(host, port)` from a target reference. Modbus lives on its own port
/// (502 by default), independent of any HTTP/WebVisu URL, so unless the reference
/// explicitly carries `modbus://host:port` or a bare `host:port`, we probe 502.
fn parse_endpoint(endpoint: &str) -> (String, u16) {
    let s = endpoint.trim();
    let (scheme, rest) = match s.split_once("://") {
        Some((sch, r)) => (Some(sch.to_ascii_lowercase()), r),
        None => (None, s),
    };
    let hostport = rest.split(['/', '?']).next().unwrap_or(rest);
    let (host, port) = match hostport.rsplit_once(':') {
        Some((h, p)) => (h.to_string(), p.parse::<u16>().ok()),
        None => (hostport.to_string(), None),
    };
    let port = match (scheme.as_deref(), port) {
        // Explicit Modbus port, or a bare host:port the user chose.
        (Some("modbus"), Some(p)) | (None, Some(p)) => p,
        // An http(s)/WebVisu URL (or no port): Modbus is on its own port.
        _ => MODBUS_PORT,
    };
    (host, port)
}

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

    #[test]
    fn endpoint_parsing_picks_the_modbus_port() {
        assert_eq!(parse_endpoint("10.0.0.5"), ("10.0.0.5".into(), 502));
        assert_eq!(parse_endpoint("10.0.0.5:1502"), ("10.0.0.5".into(), 1502));
        assert_eq!(
            parse_endpoint("modbus://plc.local:5020"),
            ("plc.local".into(), 5020)
        );
        // A WebVisu URL: the http port is ignored; Modbus is on 502.
        assert_eq!(
            parse_endpoint("http://plc.local:8080/webvisu"),
            ("plc.local".into(), 502)
        );
        assert_eq!(
            parse_endpoint("https://plc.local/"),
            ("plc.local".into(), 502)
        );
    }
}
