//! Smart port allocation to prevent conflicts between apps.
//!
//! Tracks which host ports are in use and allocates free ports for new apps.
//! Persists allocations to disk so they survive restarts.

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;

/// Ports reserved by system/deploy services (LND UI, Mempool, etc.).
/// These are never allocated to user-installed apps.
const RESERVED_PORTS: &[u16] = &[
    80, 443, 81,  // HTTP/HTTPS
    8332, 8333, 8334,  // Bitcoin RPC/P2P
    9735, 10009, 8080,  // LND P2P, gRPC, REST
    8081,  // LND UI (archy-lnd-ui)
    4080, 8999, 50001,  // Mempool stack
    23000,  // BTCPay
    8173, 8174, 8175,  // Fedimint
    8123,  // Home Assistant
    3000,  // Grafana
    11434,  // Ollama
    9980, 9001,  // OnlyOffice, Penpot
    8240,  // Tailscale
    9000,  // Portainer
    3001,  // Uptime Kuma
    8888,  // SearXNG
    8096, 2342, 2283,  // Jellyfin, Photoprism, Immich
    8443, 8084,  // NPM
];

/// Start of range for allocating web app ports when preferred is taken.
const WEB_PORT_RANGE_START: u16 = 8085;
const WEB_PORT_RANGE_END: u16 = 9999;

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct PortAllocations {
    /// app_id -> (host_port, container_port)
    allocations: HashMap<String, PortMapping>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct PortMapping {
    host_port: u16,
    container_port: u16,
}

pub struct PortAllocator {
    data_dir: std::path::PathBuf,
    allocations: PortAllocations,
}

impl PortAllocator {
    pub fn new(data_dir: impl AsRef<Path>) -> Result<Self> {
        let data_dir = data_dir.as_ref().to_path_buf();
        let path = data_dir.join("port_allocations.json");
        let allocations = if path.exists() {
            let s = std::fs::read_to_string(&path)
                .context("Failed to read port allocations")?;
            serde_json::from_str(&s).unwrap_or_default()
        } else {
            PortAllocations::default()
        };
        Ok(Self {
            data_dir,
            allocations,
        })
    }

    fn save(&self) -> Result<()> {
        let path = self.data_dir.join("port_allocations.json");
        std::fs::create_dir_all(&self.data_dir)
            .context("Failed to create data dir for port allocations")?;
        let s = serde_json::to_string_pretty(&self.allocations)
            .context("Failed to serialize port allocations")?;
        std::fs::write(&path, s).context("Failed to write port allocations")?;
        Ok(())
    }

    fn is_reserved(&self, port: u16) -> bool {
        RESERVED_PORTS.contains(&port)
    }

    fn is_allocated(&self, port: u16) -> bool {
        self.allocations
            .allocations
            .values()
            .any(|m| m.host_port == port)
    }

    fn is_available(&self, port: u16) -> bool {
        !self.is_reserved(port) && !self.is_allocated(port)
    }

    /// Allocate a host port for an app. Uses preferred_port if available, else finds next free.
    pub fn allocate(
        &mut self,
        app_id: &str,
        preferred_host_port: u16,
        container_port: u16,
    ) -> Result<u16> {
        let host_port = if self.is_available(preferred_host_port) {
            preferred_host_port
        } else {
            (WEB_PORT_RANGE_START..=WEB_PORT_RANGE_END)
                .find(|&p| self.is_available(p))
                .ok_or_else(|| anyhow::anyhow!("No free port in range {}-{}", WEB_PORT_RANGE_START, WEB_PORT_RANGE_END))?
        };

        self.allocations.allocations.insert(
            app_id.to_string(),
            PortMapping {
                host_port,
                container_port,
            },
        );
        self.save()?;
        Ok(host_port)
    }

    /// Get existing allocation for an app, if any.
    pub fn get(&self, app_id: &str) -> Option<(u16, u16)> {
        self.allocations.allocations.get(app_id).map(|m| {
            (m.host_port, m.container_port)
        })
    }

    /// Allocate or return existing. Use when installing/starting an app.
    pub fn allocate_or_get(
        &mut self,
        app_id: &str,
        preferred_host_port: u16,
        container_port: u16,
    ) -> Result<u16> {
        if let Some((host, _)) = self.get(app_id) {
            return Ok(host);
        }
        self.allocate(app_id, preferred_host_port, container_port)
    }

    /// Release port when app is uninstalled.
    pub fn release(&mut self, app_id: &str) -> Result<()> {
        self.allocations.allocations.remove(app_id);
        self.save()?;
        Ok(())
    }
}
