//! TCP service discovery for a device.
//!
//! Connect-scans a curated set of OT/ICS and insecure-management ports and reports
//! the ones that are open. The deep protocol probes own Modbus (502), OPC UA
//! (4840) and EtherNet/IP (44818); this surfaces the *rest* of the industrial and
//! cleartext-management surface (Siemens S7, DNP3, CODESYS programming, Telnet, …).

use std::time::Duration;

use futures_util::future::join_all;
use tokio::net::TcpStream;
use tokio::time::timeout;

/// Whether an open port is an industrial protocol or an insecure management service.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PortKind {
    /// An industrial control protocol (typically unauthenticated).
    Ics,
    /// A cleartext management service (credentials/data in the clear).
    InsecureMgmt,
}

/// A well-known port worth flagging when open.
#[derive(Debug, Clone, Copy)]
pub struct KnownPort {
    pub port: u16,
    pub service: &'static str,
    pub kind: PortKind,
    pub note: &'static str,
}

/// The curated scan list. Excludes 502 / 4840 / 44818 — those have dedicated deep
/// probes (Modbus, OPC UA, EtherNet/IP) that report richer findings.
pub const KNOWN_PORTS: &[KnownPort] = &[
    KnownPort {
        port: 102,
        service: "S7comm / ISO-TSAP",
        kind: PortKind::Ics,
        note: "Siemens S7 PLC communication",
    },
    KnownPort {
        port: 20000,
        service: "DNP3",
        kind: PortKind::Ics,
        note: "SCADA / DNP3",
    },
    KnownPort {
        port: 1911,
        service: "Niagara Fox",
        kind: PortKind::Ics,
        note: "Tridium Niagara building automation",
    },
    KnownPort {
        port: 11740,
        service: "CODESYS",
        kind: PortKind::Ics,
        note: "CODESYS programming protocol",
    },
    KnownPort {
        port: 1962,
        service: "PCWorx",
        kind: PortKind::Ics,
        note: "Phoenix Contact PCWorx",
    },
    KnownPort {
        port: 9600,
        service: "OMRON FINS",
        kind: PortKind::Ics,
        note: "Omron FINS",
    },
    KnownPort {
        port: 789,
        service: "Red Lion Crimson",
        kind: PortKind::Ics,
        note: "Red Lion controllers",
    },
    KnownPort {
        port: 23,
        service: "Telnet",
        kind: PortKind::InsecureMgmt,
        note: "cleartext remote shell",
    },
    KnownPort {
        port: 21,
        service: "FTP",
        kind: PortKind::InsecureMgmt,
        note: "cleartext file transfer",
    },
];

/// Connect-scan `ports` on `host` (concurrently) and return those that accept a
/// TCP connection.
pub async fn scan<'a>(host: &str, ports: &'a [KnownPort], budget: Duration) -> Vec<&'a KnownPort> {
    let checks = ports.iter().map(|kp| async move {
        let open = timeout(budget, TcpStream::connect((host, kp.port)))
            .await
            .map(|r| r.is_ok())
            .unwrap_or(false);
        (kp, open)
    });
    join_all(checks)
        .await
        .into_iter()
        .filter_map(|(kp, open)| open.then_some(kp))
        .collect()
}

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

    #[tokio::test]
    async fn scan_reports_only_open_ports() {
        // Bind one port (open) and pick another that is closed.
        let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
        let open_port = listener.local_addr().expect("addr").port();

        let ports = [
            KnownPort {
                port: open_port,
                service: "test-open",
                kind: PortKind::Ics,
                note: "",
            },
            KnownPort {
                port: 1,
                service: "test-closed",
                kind: PortKind::InsecureMgmt,
                note: "",
            },
        ];
        let found = scan("127.0.0.1", &ports, Duration::from_millis(500)).await;
        let services: Vec<&str> = found.iter().map(|p| p.service).collect();
        assert_eq!(services, vec!["test-open"]);
    }
}
