//! OpenPXE entry point. Wires the three protocol servers (DHCP proxy,
//! TFTP, HTTP) to the shared ISO store and client registry, then runs
//! them concurrently.

use clap::{Parser, Subcommand};
use openpxe_core::{
    ClientRegistry, Config, DeploymentQueue, DhcpMode, HostBindings, LogBus, LogBusLayer, Metrics,
    SettingsStore,
};
use openpxe_dhcp_proxy::DhcpProxyServer;
use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsShareManager, SftpShareManager, SmbManager, SmbShareManager};
use openpxe_tftp::TftpServer;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::io::AsyncReadExt;

#[derive(Debug, Parser)]
#[command(name = "openpxe", about = "Container-native PXE boot server", version)]
struct Cli {
    /// Path to a TOML config file. All fields have sensible defaults and can
    /// also be overridden with env vars (OPENPXE_*).
    #[arg(long, env = "OPENPXE_CONFIG")]
    config: Option<PathBuf>,

    #[command(subcommand)]
    command: Option<Command>,
}

#[derive(Debug, Subcommand)]
enum Command {
    /// Import every `.iso` from a host directory into the ISO store, running
    /// the same introspection + boot-entry generation pass the web upload
    /// does. Useful for pre-populating the store before starting the server
    /// (e.g. in a CI setup or after copying ISOs to a fresh PVC).
    ///
    /// Example:
    ///   docker run --rm \
    ///     -v /my/isos:/seed:ro \
    ///     -v openpxe-data:/var/lib/openpxe/isos \
    ///     openpxe:0.4.1 seed --from /seed
    Seed {
        /// Source directory containing one or more `.iso` files.
        #[arg(long)]
        from: PathBuf,
        /// Don't actually import — print what would happen.
        #[arg(long)]
        dry_run: bool,
    },
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // The LogBus has to exist before we install tracing layers, since one
    // of those layers fans out into it. The web UI's Terminal tab
    // subscribes to this bus over SSE.
    let log_bus = LogBus::new(500);
    init_tracing(log_bus.clone());

    let cli = Cli::parse();
    // v0.5.4: layered load via figment — defaults → optional TOML → env.
    // The OPENPXE_* env layer keeps the historical flat names (see
    // `Config::load`), so existing deployments are unaffected.
    let config = Config::load(cli.config.as_deref())?;

    // Dispatch subcommands before bringing up the server.
    if let Some(cmd) = cli.command {
        return run_command(cmd, config).await;
    }

    openpxe_ipxe_assets::log_availability();

    let our_ip = match config.server.public_ip {
        Some(ip) => {
            tracing::info!("using configured public IP: {ip}");
            ip
        }
        None => match detect_primary_ipv4() {
            Some(ip) => {
                tracing::info!("auto-detected public IPv4: {ip}");
                ip
            }
            None => {
                // Without a reachable IP, every generated iPXE URL would
                // be unreachable from real clients. Exit with a clear
                // message instead of serving a broken deployment.
                anyhow::bail!(
                    "could not detect a non-loopback IPv4 address for this host. \
                     Set OPENPXE_PUBLIC_IP=<your-ip> (e.g. `-e OPENPXE_PUBLIC_IP=10.0.0.5` \
                     in docker, or the env block in OpenShift Deployment) to advertise \
                     a specific IP to PXE clients."
                );
            }
        },
    };
    // v0.5.6: the advertised base URL must carry the HTTP port. Every
    // client-facing URL (the DHCP-proxy iPXE filename, UEFI HTTP boot,
    // and the menu's kernel/initrd/ISO links) is derived from this one
    // string, so omitting the port silently pointed PXE clients at :80 —
    // breaking every non-80 deployment (e.g. the Unraid template's 4200,
    // chosen to dodge the webGUI). See `build_public_base_url`.
    let public_base_url = build_public_base_url(our_ip, config.server.http_port);

    let iso_store = IsoStore::new(config.paths.iso_dir.clone());
    iso_store.load_from_disk().await?;
    // v0.5.2: unattended answer-file store (Kickstart/Preseed/Autoinstall/
    // Windows answer files). Separate directory from the ISO store.
    let unattended = openpxe_iso_store::UnattendedStore::new(config.paths.unattended_dir.clone());
    if let Err(e) = unattended.load_from_disk().await {
        tracing::warn!(
            target: "openpxe::unattended",
            "could not load unattended files on startup: {e}"
        );
    }
    let clients = ClientRegistry::new();
    let queue = DeploymentQueue::new();
    let settings = SettingsStore::load_or_default(&config.paths.work_dir);
    let hosts = HostBindings::load_or_default(&config.paths.work_dir);
    let boot_log = openpxe_core::BootLog::load_or_default(&config.paths.work_dir);
    let branding = openpxe_core::BrandingStore::load_or_default(&config.paths.work_dir);
    let admin = openpxe_core::AdminStore::load_or_default(&config.paths.work_dir);
    let sso = openpxe_core::SsoStore::load_or_default(&config.paths.work_dir);
    let notify = openpxe_core::NotifyStore::load_or_default(&config.paths.work_dir);
    let sessions = openpxe_http_api::auth::SessionStore::default();
    let metrics = Metrics::new();

    // Build the SMB manager unconditionally — it starts/stops on the
    // Windows toggle, not at process start. If the `smb_dir` isn't
    // writable (e.g. read-only filesystem), the manager will surface that
    // as `SmbState::Failed` when the operator flips the toggle.
    let smb = Arc::new(SmbManager::new(config.paths.smb_dir.clone()));
    if settings.snapshot().windows_enabled {
        let _ = smb.start();
    }

    // v0.4.65: SMB share manager — Samba `smbclient` userspace
    // consumer. Replaces the kernel-mount NFS path that v0.4.64
    // shipped; that didn't work on hosts whose kernel lacked the nfs
    // client modules (Unraid is the dominant case). `smbclient` does
    // the SMB protocol entirely in userspace over TCP and works in
    // any container regardless of capabilities or kernel modules.
    let smb_shares = SmbShareManager::new(&config.paths.work_dir, iso_store.clone());
    if let Err(e) = smb_shares.load_and_rescan().await {
        tracing::warn!(
            target: "openpxe::smb",
            "could not reload SMB shares on startup: {e}"
        );
    }

    // v0.4.67: NFSv3 share manager — pure-Rust in-process consumer
    // via the `nfs3_client` crate. Sits alongside the SMB manager;
    // operators pick whichever protocol their NAS prefers, or use
    // both. No subprocess, no kernel mount, works in any container.
    let nfs_shares = NfsShareManager::new(&config.paths.work_dir, iso_store.clone());
    if let Err(e) = nfs_shares.load_and_rescan().await {
        tracing::warn!(
            target: "openpxe::nfs",
            "could not reload NFS shares on startup: {e}"
        );
    }

    // v0.5.5: SFTP-over-SSH share manager — pure-Rust in-process
    // consumer via `russh` + `russh-sftp` (ring backend, no OpenSSL).
    // The third remote-library protocol alongside SMB/NFS; like NFS it
    // works in any container (no subprocess, no kernel mount) and
    // supports HTTP Range requests because SFTP file handles seek.
    let sftp_shares = SftpShareManager::new(&config.paths.work_dir, iso_store.clone());
    if let Err(e) = sftp_shares.load_and_rescan().await {
        tracing::warn!(
            target: "openpxe::sftp",
            "could not reload SFTP shares on startup: {e}"
        );
    }

    // Sniff network details for the Network tab. None of these are
    // required for PXE to work — they're informational, surfaced in the
    // UI so an operator doesn't have to drop to a shell to find their
    // own gateway.
    let net = detect_network_info(our_ip);
    tracing::info!(
        target: "openpxe::net",
        nic = %net.nic_name, mask = %net.subnet_mask, gateway = %net.gateway,
        "network info"
    );

    let state = AppState {
        iso_store: iso_store.clone(),
        clients: clients.clone(),
        settings: settings.clone(),
        queue: queue.clone(),
        hosts: hosts.clone(),
        boot_log: boot_log.clone(),
        branding: branding.clone(),
        admin: admin.clone(),
        sessions: sessions.clone(),
        sso: sso.clone(),
        saml: openpxe_http_api::saml_routes::SamlRuntime::default(),
        notify: notify.clone(),
        metrics: metrics.clone(),
        smb: Some(smb.clone()),
        smb_shares: smb_shares.clone(),
        nfs_shares: nfs_shares.clone(),
        sftp_shares: sftp_shares.clone(),
        unattended: unattended.clone(),
        uploads: openpxe_http_api::uploads::UploadSessions::default(),
        log_bus: log_bus.clone(),
        started_at: time::OffsetDateTime::now_utc(),
        public_base_url: public_base_url.clone(),
        nic_name: net.nic_name,
        subnet_mask: net.subnet_mask,
        gateway: net.gateway,
    };

    let http_addr = SocketAddr::new(config.server.http_bind, config.server.http_port);
    let router = build_router(state);
    let http_task = tokio::spawn(async move {
        let listener = tokio::net::TcpListener::bind(http_addr).await?;
        tracing::info!(target: "openpxe::http", "HTTP listening on {http_addr}");
        // `into_make_service_with_connect_info` is required so per-request
        // `ConnectInfo<SocketAddr>` extractors can resolve the peer IP —
        // used by `/boot/<entry>.ipxe` to record the booting client's
        // address into the Host log. Without this the extractor 500s.
        axum::serve(
            listener,
            router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
        )
        .await?;
        Ok::<_, anyhow::Error>(())
    });

    let tftp = TftpServer::new(
        config.server.tftp_bind,
        config.server.tftp_port,
        clients.clone(),
        metrics.clone(),
    );
    let tftp_task = tokio::spawn(tftp.run());

    let dhcp_task: tokio::task::JoinHandle<anyhow::Result<()>> = match config.network.dhcp_mode {
        DhcpMode::Proxy => {
            let s = DhcpProxyServer::new(
                config.network.dhcp_bind,
                config.network.dhcp_port,
                config.network.pxe_port,
                our_ip,
                public_base_url.clone(),
                clients.clone(),
                metrics.clone(),
            );
            tokio::spawn(s.run())
        }
        DhcpMode::Disabled => {
            tracing::info!(target: "openpxe::dhcp", "DHCP disabled — external DHCP must set next-server + filename");
            tokio::spawn(async { futures_forever().await })
        }
    };

    tokio::select! {
        r = http_task => { tracing::error!("http task exited: {:?}", r); r??; }
        r = tftp_task => { tracing::error!("tftp task exited: {:?}", r); r??; }
        r = dhcp_task => { tracing::error!("dhcp task exited: {:?}", r); r??; }
    }
    Ok(())
}

async fn futures_forever() -> anyhow::Result<()> {
    std::future::pending::<()>().await;
    Ok(())
}

async fn run_command(cmd: Command, config: Config) -> anyhow::Result<()> {
    match cmd {
        Command::Seed { from, dry_run } => seed_from_dir(&from, &config, dry_run).await,
    }
}

/// Walk `src` for `*.iso`, stream each file through the normal upload path.
/// Reuses `IsoStore::begin_upload` / `finish` so the resulting meta on disk
/// is identical to a web upload — same slug rules, same introspection, same
/// sha256.
async fn seed_from_dir(
    src: &std::path::Path,
    config: &Config,
    dry_run: bool,
) -> anyhow::Result<()> {
    let store = IsoStore::new(config.paths.iso_dir.clone());
    store.load_from_disk().await?;
    let mut entries = tokio::fs::read_dir(src).await?;
    let mut imported = 0u32;
    let mut skipped = 0u32;
    while let Some(entry) = entries.next_entry().await? {
        let p = entry.path();
        if p.extension()
            .and_then(|e| e.to_str())
            .map(str::to_ascii_lowercase)
            .as_deref()
            != Some("iso")
        {
            continue;
        }
        let filename = p
            .file_name()
            .and_then(|s| s.to_str())
            .ok_or_else(|| anyhow::anyhow!("non-utf8 filename: {}", p.display()))?
            .to_string();
        println!(
            "  {} ({} bytes)",
            filename,
            tokio::fs::metadata(&p).await?.len()
        );
        if dry_run {
            continue;
        }

        let mut handle = match store.begin_upload(&filename).await {
            Ok(h) => h,
            Err(openpxe_core::Error::Invalid(e)) => {
                eprintln!("    skip: {e}");
                skipped += 1;
                continue;
            }
            Err(e) => return Err(e.into()),
        };
        let mut file = tokio::fs::File::open(&p).await?;
        let mut buf = vec![0u8; 1024 * 1024];
        loop {
            let n = file.read(&mut buf).await?;
            if n == 0 {
                break;
            }
            let chunk: bytes::Bytes = buf[..n].to_vec().into();
            handle.write_chunk(&chunk).await?;
        }
        let meta = handle.finish(&store).await?;
        println!(
            "    -> id={} family={:?}",
            meta.id, meta.introspection.family
        );
        imported += 1;
    }
    println!(
        "\nimported={imported}  skipped={skipped}  {}",
        if dry_run { "(dry run)" } else { "" }
    );
    Ok(())
}

/// Pick the first non-loopback IPv4 address on this host. Returns `None` if
/// detection fails — callers should fail startup rather than silently using
/// a loopback address (which would give every PXE client an unreachable
/// `http://127.0.0.1/...`). Users in multi-homed setups should set
/// `OPENPXE_PUBLIC_IP` explicitly.
/// Build the base URL advertised to PXE clients. The port is included
/// unless it's the HTTP default (80), keeping the common case clean
/// (`http://10.0.0.5`) while a remapped port (`http://10.0.0.5:4200`)
/// stays reachable. This is the single source of truth for every
/// client-facing URL — the DHCP-proxy iPXE filename, UEFI HTTP boot, and
/// the boot menu's kernel/initrd/ISO links all derive from it.
fn build_public_base_url(ip: Ipv4Addr, http_port: u16) -> String {
    if http_port == 80 {
        format!("http://{ip}")
    } else {
        format!("http://{ip}:{http_port}")
    }
}

fn detect_primary_ipv4() -> Option<Ipv4Addr> {
    // First try: route to the public internet. `UdpSocket::connect` to a
    // well-known external address causes the OS to populate `local_addr`
    // with the source IP it would use — this is the standard "which of my
    // interfaces is the internet-facing one" idiom.
    if let Ok(sock) = std::net::UdpSocket::bind("0.0.0.0:0") {
        if sock.connect("8.8.8.8:80").is_ok() {
            if let Ok(std::net::SocketAddr::V4(addr)) = sock.local_addr() {
                let v4 = *addr.ip();
                if !v4.is_loopback() && !v4.is_unspecified() {
                    return Some(v4);
                }
            }
        }
    }
    // Fallback: hostname resolution.
    if let Ok(hostname) = hostname() {
        if let Ok(addrs) = std::net::ToSocketAddrs::to_socket_addrs(&format!("{hostname}:0")) {
            for a in addrs {
                if let std::net::IpAddr::V4(v4) = a.ip() {
                    if !v4.is_loopback() && !v4.is_unspecified() {
                        return Some(v4);
                    }
                }
            }
        }
    }
    None
}

fn hostname() -> std::io::Result<String> {
    // Tiny shim: read /proc/sys/kernel/hostname on Linux, fall back to `uname -n` via env.
    if let Ok(h) = std::fs::read_to_string("/proc/sys/kernel/hostname") {
        return Ok(h.trim().to_string());
    }
    std::env::var("HOSTNAME")
        .map_err(|_| std::io::Error::new(std::io::ErrorKind::NotFound, "no hostname"))
}

fn init_tracing(bus: Arc<LogBus>) {
    use tracing_subscriber::{fmt, prelude::*, EnvFilter};
    let filter = EnvFilter::try_from_env("OPENPXE_LOG")
        .unwrap_or_else(|_| EnvFilter::new("info,openpxe=debug"));
    tracing_subscriber::registry()
        .with(filter)
        .with(fmt::layer().with_target(true))
        .with(LogBusLayer::new(bus))
        .init();
}

#[derive(Debug, Default)]
struct NetworkInfo {
    nic_name: String,
    subnet_mask: String,
    gateway: String,
}

/// Best-effort population of the Network tab's read-only fields. We shell
/// out to standard Linux tools (`ip route`) instead of pulling in a
/// netlink crate — these calls run once at startup, so the cost of
/// spawning a process is negligible. Empty strings are perfectly fine
/// fallbacks; the UI shows a `?` placeholder.
fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
    use std::process::Command;
    let mut info = NetworkInfo::default();

    // `ip -o -f inet addr show` lists every interface with its
    // `inet a.b.c.d/mask`. We match the line that mentions our IP.
    if let Ok(out) = Command::new("ip")
        .args(["-o", "-f", "inet", "addr", "show"])
        .output()
    {
        if let Ok(text) = String::from_utf8(out.stdout) {
            for line in text.lines() {
                if !line.contains(&our_ip.to_string()) {
                    continue;
                }
                // Format: "2: enp1s0    inet 10.0.0.5/24 brd ..."
                let mut parts = line.split_whitespace();
                let _idx = parts.next();
                if let Some(name) = parts.next() {
                    info.nic_name = name.trim_end_matches(':').to_string();
                }
                if let Some(addr) = line.split_whitespace().find(|p| p.contains('/')) {
                    if let Some((_, prefix_str)) = addr.split_once('/') {
                        if let Ok(prefix) = prefix_str.parse::<u8>() {
                            info.subnet_mask = prefix_to_dotted(prefix);
                        }
                    }
                }
                break;
            }
        }
    }

    // `ip route show default` -> "default via 10.0.0.1 dev enp1s0 ..."
    if let Ok(out) = Command::new("ip")
        .args(["route", "show", "default"])
        .output()
    {
        if let Ok(text) = String::from_utf8(out.stdout) {
            if let Some(line) = text.lines().next() {
                let mut parts = line.split_whitespace();
                while let Some(p) = parts.next() {
                    if p == "via" {
                        if let Some(gw) = parts.next() {
                            info.gateway = gw.to_string();
                        }
                        break;
                    }
                }
            }
        }
    }

    info
}

fn prefix_to_dotted(prefix: u8) -> String {
    let prefix = prefix.min(32);
    let mask: u32 = if prefix == 0 {
        0
    } else {
        u32::MAX << (32 - prefix)
    };
    format!(
        "{}.{}.{}.{}",
        (mask >> 24) & 0xff,
        (mask >> 16) & 0xff,
        (mask >> 8) & 0xff,
        mask & 0xff
    )
}

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

    #[test]
    fn public_base_url_includes_non_default_port() {
        // The v0.5.6 regression guard: a remapped HTTP port (e.g. the
        // Unraid template's 4200) MUST appear in the advertised URL, or
        // PXE clients fetch :80 — the wrong service — and boot fails.
        let ip: Ipv4Addr = "192.168.1.49".parse().unwrap();
        assert_eq!(build_public_base_url(ip, 4200), "http://192.168.1.49:4200");
        assert_eq!(build_public_base_url(ip, 8080), "http://192.168.1.49:8080");
    }

    #[test]
    fn public_base_url_omits_default_port() {
        // Port 80 stays clean (no `:80`) so the common case reads nicely
        // and matches what every browser/iPXE assumes by default.
        let ip: Ipv4Addr = "10.0.0.5".parse().unwrap();
        assert_eq!(build_public_base_url(ip, 80), "http://10.0.0.5");
    }
}
