//! Desktop adapter for the embedded HTTP server lifecycle and exposure config.
//!
//! This module stays in the Tauri adapter layer: it owns persisted exposure
//! settings, starts/stops the embedded web server, and never crosses into the
//! domain/application layers.

use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

use backend::BackendCore;
use serde::{Deserialize, Serialize};
use web_server::{EmbeddedServerHandle, PairingCodeDto, ServerConfig, TrustedProxy};

use crate::dto::ErrorDto;

/// Remote exposure mode selected by the desktop settings UI.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ServerExposureMode {
    /// Bind only loopback and serve local HTTP.
    LocalOnly,
    /// Bind loopback and expect a same-machine HTTPS reverse proxy.
    RemoteProxyLocal,
    /// Bind a LAN address and expect a trusted reverse proxy on another host.
    RemoteProxyOtherMachine,
}

/// Persisted server exposure settings.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerExposureSettingsDto {
    /// Exposure mode.
    pub mode: ServerExposureMode,
    /// Whether the embedded server should start automatically when IdeA boots.
    #[serde(default)]
    pub auto_start: bool,
    /// TCP port to bind. `0` asks the OS for an ephemeral port.
    pub port: u16,
    /// Public HTTPS origin used by reverse-proxy modes.
    pub public_origin: Option<String>,
    /// Authorized proxy peers as IPs or CIDR ranges.
    #[serde(default)]
    pub trusted_proxies: Vec<String>,
    /// LAN bind address for `remoteProxyOtherMachine`.
    pub lan_bind_address: Option<String>,
}

/// Non-fatal diagnostic shown by the settings UI.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DiagnosticWarningDto {
    /// Stable warning code.
    pub code: String,
    /// Human-readable warning.
    pub message: String,
}

/// Derived exposure preview for the desktop UI.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerExposurePreviewDto {
    /// Candidate LAN addresses discovered by the backend.
    pub candidate_lan_addresses: Vec<String>,
    /// URL the reverse proxy should use as its upstream.
    pub upstream_url: Option<String>,
    /// Non-fatal diagnostics.
    pub warnings: Vec<DiagnosticWarningDto>,
}

/// Current embedded server lifecycle state exposed to the desktop UI.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum EmbeddedServerStatusStateDto {
    /// No embedded server is running.
    Stopped,
    /// Start is in progress.
    Starting,
    /// Server is running.
    Running,
    /// Stop is in progress.
    Stopping,
    /// Last start/stop failed.
    Failed,
}

/// Embedded server status exposed to the desktop UI.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmbeddedServerStatusDto {
    /// Lifecycle state.
    pub state: EmbeddedServerStatusStateDto,
    /// Local URL, when running.
    pub local_url: Option<String>,
    /// Public URL, when configured.
    pub public_url: Option<String>,
    /// Reverse-proxy upstream URL derived from settings.
    pub upstream_url: Option<String>,
    /// Last failure, when state is `failed`.
    pub error: Option<ErrorDto>,
}

#[derive(Default)]
struct EmbeddedServerInner {
    handle: Option<EmbeddedServerHandle>,
    state: EmbeddedServerStatusStateDto,
    upstream_url: Option<String>,
    public_url: Option<String>,
    error: Option<ErrorDto>,
}

impl Default for EmbeddedServerStatusStateDto {
    fn default() -> Self {
        Self::Stopped
    }
}

/// Filesystem-backed exposure settings store.
#[derive(Debug, Clone)]
pub struct FsServerExposureSettingsStore {
    path: PathBuf,
}

impl FsServerExposureSettingsStore {
    /// Creates a store at `<app-data-dir>/deployment/server-exposure.json`.
    #[must_use]
    pub fn new(app_data_dir: PathBuf) -> Self {
        Self {
            path: app_data_dir.join("deployment").join("server-exposure.json"),
        }
    }

    /// Reads persisted settings, or returns defaults when no file exists.
    ///
    /// # Errors
    /// Returns an [`ErrorDto`] when the file exists but is invalid.
    pub fn read(&self) -> Result<ServerExposureSettingsDto, ErrorDto> {
        if !self.path.exists() {
            return Ok(default_settings());
        }
        let bytes = std::fs::read(&self.path).map_err(io_error)?;
        let settings = serde_json::from_slice::<ServerExposureSettingsDto>(&bytes)
            .map_err(|err| invalid_error(format!("invalid server exposure settings: {err}")))?;
        validate_settings(&settings)?;
        Ok(settings)
    }

    /// Validates and atomically writes settings with user-only permissions when
    /// the platform supports them.
    ///
    /// # Errors
    /// Returns an [`ErrorDto`] when validation or persistence fails.
    pub fn write(&self, settings: &ServerExposureSettingsDto) -> Result<(), ErrorDto> {
        validate_settings(settings)?;
        let bytes = serde_json::to_vec_pretty(settings).map_err(|err| ErrorDto {
            code: "INTERNAL".to_owned(),
            message: err.to_string(),
        })?;
        write_atomic_user_only(&self.path, &bytes).map_err(io_error)
    }
}

/// Desktop-owned embedded server manager.
pub struct EmbeddedServerController {
    app_data_dir: PathBuf,
    resource_dir: Option<PathBuf>,
    store: FsServerExposureSettingsStore,
    inner: Mutex<EmbeddedServerInner>,
}

impl EmbeddedServerController {
    /// Creates the controller.
    #[must_use]
    pub fn new(app_data_dir: PathBuf) -> Self {
        Self::with_resource_dir(app_data_dir, None)
    }

    /// Creates the controller with the Tauri bundle resource directory.
    #[must_use]
    pub fn with_resource_dir(app_data_dir: PathBuf, resource_dir: Option<PathBuf>) -> Self {
        Self {
            store: FsServerExposureSettingsStore::new(app_data_dir.clone()),
            app_data_dir,
            resource_dir,
            inner: Mutex::new(EmbeddedServerInner::default()),
        }
    }

    /// Returns the current status.
    #[must_use]
    pub fn status(&self) -> EmbeddedServerStatusDto {
        let inner = self.inner.lock().expect("embedded server mutex poisoned");
        status_from_inner(&inner)
    }

    /// Reads persisted exposure settings.
    ///
    /// # Errors
    /// Returns an [`ErrorDto`] if persisted settings are invalid.
    pub fn get_settings(&self) -> Result<ServerExposureSettingsDto, ErrorDto> {
        self.store.read()
    }

    /// Persists exposure settings.
    ///
    /// # Errors
    /// Returns an [`ErrorDto`] if settings are invalid or cannot be written.
    pub fn save_settings(&self, settings: ServerExposureSettingsDto) -> Result<(), ErrorDto> {
        self.store.write(&settings)
    }

    /// Builds a preview for the provided settings without starting the server.
    ///
    /// # Errors
    /// Returns an [`ErrorDto`] if settings are invalid.
    pub fn preview(
        &self,
        settings: ServerExposureSettingsDto,
    ) -> Result<ServerExposurePreviewDto, ErrorDto> {
        validate_settings(&settings)?;
        Ok(preview_settings(&settings))
    }

    /// Starts the embedded server. Idempotent when already running/starting.
    ///
    /// # Errors
    /// Returns an [`ErrorDto`] if settings are invalid or the listener fails.
    pub async fn start(&self, core: Arc<BackendCore>) -> Result<EmbeddedServerStatusDto, ErrorDto> {
        {
            let mut inner = self.inner.lock().expect("embedded server mutex poisoned");
            if inner.handle.is_some() || inner.state == EmbeddedServerStatusStateDto::Starting {
                return Ok(status_from_inner(&inner));
            }
            inner.state = EmbeddedServerStatusStateDto::Starting;
            inner.error = None;
        }

        let settings = match self.store.read() {
            Ok(settings) => settings,
            Err(err) => {
                self.mark_failed(err.clone());
                return Err(err);
            }
        };
        let preview = preview_settings(&settings);
        let config = match server_config_from_settings(
            &settings,
            self.app_data_dir.clone(),
            resolve_web_root(self.resource_dir.as_deref())?,
        ) {
            Ok(config) => config,
            Err(err) => {
                self.mark_failed(err.clone());
                return Err(err);
            }
        };

        match web_server::run_embedded_with_core(config, core).await {
            Ok(handle) => {
                let status = {
                    let mut inner = self.inner.lock().expect("embedded server mutex poisoned");
                    inner.public_url = settings.public_origin.clone();
                    inner.upstream_url = preview.upstream_url;
                    inner.state = EmbeddedServerStatusStateDto::Running;
                    inner.error = None;
                    inner.handle = Some(handle);
                    status_from_inner(&inner)
                };
                Ok(status)
            }
            Err(message) => {
                let err = ErrorDto {
                    code: start_error_code(&message).to_owned(),
                    message,
                };
                self.mark_failed(err.clone());
                Err(err)
            }
        }
    }

    /// Starts the embedded server at application boot when persisted settings
    /// opt into auto-start.
    ///
    /// # Errors
    /// Returns an [`ErrorDto`] if persisted settings are invalid or start fails.
    pub async fn auto_start_if_enabled(
        &self,
        core: Arc<BackendCore>,
    ) -> Result<Option<EmbeddedServerStatusDto>, ErrorDto> {
        let settings = match self.store.read() {
            Ok(settings) => settings,
            Err(err) => {
                self.mark_failed(err.clone());
                return Err(err);
            }
        };
        if !settings.auto_start {
            return Ok(None);
        }
        self.start(core).await.map(Some)
    }

    /// Generates a new ephemeral pairing code on the running embedded server.
    ///
    /// # Errors
    /// Returns an [`ErrorDto`] if the embedded server is not running.
    pub fn generate_pairing_code(&self) -> Result<PairingCodeDto, ErrorDto> {
        let inner = self.inner.lock().expect("embedded server mutex poisoned");
        let Some(handle) = inner.handle.as_ref() else {
            return Err(ErrorDto {
                code: "UNAVAILABLE".to_owned(),
                message: "embedded server is not running".to_owned(),
            });
        };
        Ok(handle.generate_pairing_code())
    }

    /// Stops the embedded server. Idempotent when already stopped.
    ///
    /// # Errors
    /// Returns an [`ErrorDto`] when stopping the listener task fails.
    pub async fn stop(&self) -> Result<EmbeddedServerStatusDto, ErrorDto> {
        let handle = {
            let mut inner = self.inner.lock().expect("embedded server mutex poisoned");
            let Some(handle) = inner.handle.take() else {
                inner.state = EmbeddedServerStatusStateDto::Stopped;
                inner.public_url = None;
                inner.upstream_url = None;
                return Ok(status_from_inner(&inner));
            };
            inner.state = EmbeddedServerStatusStateDto::Stopping;
            handle
        };

        if let Err(message) = handle.stop().await {
            let err = ErrorDto {
                code: "PROCESS".to_owned(),
                message,
            };
            self.mark_failed(err.clone());
            return Err(err);
        }

        let mut inner = self.inner.lock().expect("embedded server mutex poisoned");
        inner.state = EmbeddedServerStatusStateDto::Stopped;
        inner.public_url = None;
        inner.upstream_url = None;
        inner.error = None;
        Ok(status_from_inner(&inner))
    }

    fn mark_failed(&self, err: ErrorDto) {
        let mut inner = self.inner.lock().expect("embedded server mutex poisoned");
        inner.handle = None;
        inner.state = EmbeddedServerStatusStateDto::Failed;
        inner.public_url = None;
        inner.upstream_url = None;
        inner.error = Some(err);
    }
}

fn status_from_inner(inner: &EmbeddedServerInner) -> EmbeddedServerStatusDto {
    let error = (inner.state == EmbeddedServerStatusStateDto::Failed)
        .then(|| inner.error.clone())
        .flatten();
    EmbeddedServerStatusDto {
        state: inner.state,
        local_url: inner.handle.as_ref().map(|handle| handle.url().to_owned()),
        public_url: inner.public_url.clone(),
        upstream_url: inner.upstream_url.clone(),
        error,
    }
}

fn default_settings() -> ServerExposureSettingsDto {
    ServerExposureSettingsDto {
        mode: ServerExposureMode::LocalOnly,
        auto_start: false,
        port: 17373,
        public_origin: None,
        trusted_proxies: Vec::new(),
        lan_bind_address: None,
    }
}

fn start_error_code(message: &str) -> &'static str {
    let lower = message.to_ascii_lowercase();
    if lower.contains("address already in use")
        || lower.contains("only one usage of each socket address")
        || lower.contains("addrinuse")
        || lower.contains("os error 98")
        || lower.contains("os error 48")
        || lower.contains("os error 10048")
    {
        "PORT_IN_USE"
    } else {
        "PROCESS"
    }
}

fn validate_settings(settings: &ServerExposureSettingsDto) -> Result<(), ErrorDto> {
    match settings.mode {
        ServerExposureMode::LocalOnly => {}
        ServerExposureMode::RemoteProxyLocal => {
            let origin = require_https_origin(settings)?;
            validate_origin_shape(origin)?;
        }
        ServerExposureMode::RemoteProxyOtherMachine => {
            let origin = require_https_origin(settings)?;
            validate_origin_shape(origin)?;
            let Some(addr) = &settings.lan_bind_address else {
                return Err(invalid_error(
                    "remoteProxyOtherMachine requires lanBindAddress",
                ));
            };
            let ip = parse_ip(addr, "lanBindAddress")?;
            if ip.is_loopback() || ip.is_unspecified() {
                return Err(invalid_error(
                    "remoteProxyOtherMachine requires a concrete LAN bind address",
                ));
            }
            if settings.trusted_proxies.is_empty() {
                return Err(invalid_error(
                    "remoteProxyOtherMachine requires at least one trustedProxies entry",
                ));
            }
        }
    }
    for proxy in &settings.trusted_proxies {
        TrustedProxy::parse(proxy).map_err(invalid_error)?;
    }
    Ok(())
}

fn require_https_origin(settings: &ServerExposureSettingsDto) -> Result<&str, ErrorDto> {
    let Some(origin) = settings.public_origin.as_deref() else {
        return Err(invalid_error("remote exposure requires publicOrigin"));
    };
    if !origin.starts_with("https://") {
        return Err(invalid_error(
            "remote exposure requires publicOrigin to start with https://",
        ));
    }
    Ok(origin)
}

fn validate_origin_shape(origin: &str) -> Result<(), ErrorDto> {
    if origin == "*" || origin.contains('*') {
        return Err(invalid_error("publicOrigin must be exact, not a wildcard"));
    }
    if origin.ends_with('/') {
        return Err(invalid_error("publicOrigin must not end with '/'"));
    }
    Ok(())
}

fn server_config_from_settings(
    settings: &ServerExposureSettingsDto,
    app_data_dir: PathBuf,
    web_root: PathBuf,
) -> Result<ServerConfig, ErrorDto> {
    validate_settings(settings)?;
    let listen_ip = match settings.mode {
        ServerExposureMode::LocalOnly | ServerExposureMode::RemoteProxyLocal => {
            IpAddr::V4(Ipv4Addr::LOCALHOST)
        }
        ServerExposureMode::RemoteProxyOtherMachine => parse_ip(
            settings.lan_bind_address.as_deref().unwrap_or_default(),
            "lanBindAddress",
        )?,
    };
    let allow_remote = settings.mode != ServerExposureMode::LocalOnly;
    let trusted_proxies = settings
        .trusted_proxies
        .iter()
        .map(|proxy| TrustedProxy::parse(proxy).map_err(invalid_error))
        .collect::<Result<Vec<_>, _>>()?;
    let config = ServerConfig {
        listen: SocketAddr::new(listen_ip, settings.port),
        public_origin: allow_remote
            .then(|| settings.public_origin.clone())
            .flatten(),
        allow_remote,
        trust_reverse_proxy: allow_remote,
        trusted_proxies,
        app_data_dir,
        web_root,
        new_code: false,
    };
    config.validate().map_err(invalid_error)?;
    Ok(config)
}

fn preview_settings(settings: &ServerExposureSettingsDto) -> ServerExposurePreviewDto {
    let candidate_lan_addresses = candidate_lan_addresses();
    let bind_address = match settings.mode {
        ServerExposureMode::LocalOnly | ServerExposureMode::RemoteProxyLocal => {
            Some(Ipv4Addr::LOCALHOST.to_string())
        }
        ServerExposureMode::RemoteProxyOtherMachine => settings.lan_bind_address.clone(),
    };
    let upstream_url = bind_address.map(|addr| format!("http://{addr}:{}", settings.port));
    let upstream_url = (settings.mode != ServerExposureMode::LocalOnly)
        .then_some(upstream_url)
        .flatten();
    let mut warnings = Vec::new();
    if settings.mode == ServerExposureMode::RemoteProxyOtherMachine
        && settings.trusted_proxies.is_empty()
    {
        warnings.push(DiagnosticWarningDto {
            code: "missingTrustedProxy".to_owned(),
            message:
                "Add the IP address or CIDR of the reverse proxy that connects to this server."
                    .to_owned(),
        });
    }
    ServerExposurePreviewDto {
        candidate_lan_addresses,
        upstream_url,
        warnings,
    }
}

fn candidate_lan_addresses() -> Vec<String> {
    let mut addresses = Vec::new();
    if let Ok(socket) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) {
        if socket.connect((Ipv4Addr::new(192, 0, 2, 1), 80)).is_ok() {
            if let Ok(addr) = socket.local_addr() {
                if let IpAddr::V4(ip) = addr.ip() {
                    if !ip.is_loopback() && !ip.is_unspecified() {
                        addresses.push(ip.to_string());
                    }
                }
            }
        }
    }
    addresses.sort();
    addresses.dedup();
    addresses
}

fn parse_ip(value: &str, field: &str) -> Result<IpAddr, ErrorDto> {
    value
        .parse::<IpAddr>()
        .map_err(|_| invalid_error(format!("{field} must be an IP address")))
}

fn resolve_web_root(resource_dir: Option<&Path>) -> Result<PathBuf, ErrorDto> {
    let explicit_web_root = std::env::var_os("IDEA_WEB_ROOT").map(PathBuf::from);
    let candidates = web_root_candidates(explicit_web_root, resource_dir);
    first_web_root(candidates).ok_or_else(|| {
        invalid_error("web assets not found: build frontend/dist or set IDEA_WEB_ROOT")
    })
}

fn web_root_candidates(
    explicit_web_root: Option<PathBuf>,
    resource_dir: Option<&Path>,
) -> Vec<PathBuf> {
    let mut candidates = Vec::new();
    if let Some(path) = explicit_web_root {
        candidates.push(path);
    }
    if let Some(resource_dir) = resource_dir {
        candidates.push(resource_dir.join("web"));
    }
    if let Ok(exe) = std::env::current_exe() {
        if let Some(dir) = exe.parent() {
            candidates.push(dir.join("web"));
            candidates.push(dir.join("frontend").join("dist"));
        }
    }
    if let Ok(cwd) = std::env::current_dir() {
        candidates.push(cwd.join("frontend").join("dist"));
    }
    candidates
}

fn first_web_root(candidates: impl IntoIterator<Item = PathBuf>) -> Option<PathBuf> {
    candidates
        .into_iter()
        .find(|candidate| candidate.join("index.html").is_file())
}

fn write_atomic_user_only(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let tmp = path.with_extension("json.tmp");
    std::fs::write(&tmp, bytes)?;
    set_user_only_permissions(&tmp)?;
    std::fs::rename(tmp, path)
}

#[cfg(unix)]
fn set_user_only_permissions(path: &Path) -> std::io::Result<()> {
    use std::os::unix::fs::PermissionsExt;
    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
}

#[cfg(not(unix))]
fn set_user_only_permissions(_path: &Path) -> std::io::Result<()> {
    Ok(())
}

fn invalid_error(message: impl Into<String>) -> ErrorDto {
    ErrorDto {
        code: "INVALID".to_owned(),
        message: message.into(),
    }
}

fn io_error(err: std::io::Error) -> ErrorDto {
    ErrorDto {
        code: "STORE".to_owned(),
        message: err.to_string(),
    }
}

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

    fn tmp_app_data() -> PathBuf {
        std::env::temp_dir().join(format!("idea-embedded-server-test-{}", Uuid::new_v4()))
    }

    fn tmp_web_root() -> PathBuf {
        let web_root =
            std::env::temp_dir().join(format!("idea-embedded-server-web-root-{}", Uuid::new_v4()));
        std::fs::create_dir_all(&web_root).unwrap();
        std::fs::write(
            web_root.join("index.html"),
            "<!doctype html><title>IdeA</title>",
        )
        .unwrap();
        web_root
    }

    fn tmp_resource_dir_with_web_root() -> PathBuf {
        let resource_dir =
            std::env::temp_dir().join(format!("idea-embedded-server-resource-{}", Uuid::new_v4()));
        let web_root = resource_dir.join("web");
        std::fs::create_dir_all(&web_root).unwrap();
        std::fs::write(
            web_root.join("index.html"),
            "<!doctype html><title>IdeA</title>",
        )
        .unwrap();
        resource_dir
    }

    fn loopback_bind_available() -> bool {
        std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).is_ok()
    }

    #[test]
    fn web_root_candidates_keep_packaged_resource_before_exe_fallbacks() {
        let explicit = tmp_app_data().join("explicit-web-root");
        let resource_dir = tmp_app_data().join("resources");

        let candidates = web_root_candidates(Some(explicit.clone()), Some(&resource_dir));

        assert_eq!(candidates[0], explicit);
        assert_eq!(candidates[1], resource_dir.join("web"));
    }

    #[test]
    fn first_web_root_uses_first_candidate_with_index_html() {
        let missing = tmp_app_data().join("missing-web-root");
        let resource_dir = tmp_app_data().join("resources");
        let resource_web = resource_dir.join("web");
        std::fs::create_dir_all(&resource_web).unwrap();
        std::fs::write(
            resource_web.join("index.html"),
            "<!doctype html><title>Packaged</title>",
        )
        .unwrap();
        let later = tmp_web_root();

        let resolved = first_web_root([missing, resource_web.clone(), later])
            .expect("web root should resolve");

        assert_eq!(resolved, resource_web);
    }

    #[test]
    fn non_loopback_remote_requires_trusted_proxy() {
        let settings = ServerExposureSettingsDto {
            mode: ServerExposureMode::RemoteProxyOtherMachine,
            auto_start: false,
            port: 17373,
            public_origin: Some("https://idea.example.com".to_owned()),
            trusted_proxies: Vec::new(),
            lan_bind_address: Some("192.0.2.75".to_owned()),
        };

        let err = validate_settings(&settings).unwrap_err();

        assert_eq!(err.code, "INVALID");
        assert!(err.message.contains("trustedProxies"));
    }

    #[test]
    fn remote_requires_https_public_origin() {
        let settings = ServerExposureSettingsDto {
            mode: ServerExposureMode::RemoteProxyLocal,
            auto_start: false,
            port: 17373,
            public_origin: Some("http://idea.example.com".to_owned()),
            trusted_proxies: Vec::new(),
            lan_bind_address: None,
        };

        let err = validate_settings(&settings).unwrap_err();

        assert_eq!(err.code, "INVALID");
        assert!(err.message.contains("https://"));
    }

    #[test]
    fn local_only_derives_loopback_config() {
        let settings = ServerExposureSettingsDto {
            mode: ServerExposureMode::LocalOnly,
            auto_start: false,
            port: 0,
            public_origin: Some("https://ignored.example".to_owned()),
            trusted_proxies: Vec::new(),
            lan_bind_address: None,
        };

        let config =
            server_config_from_settings(&settings, tmp_app_data(), tmp_app_data()).unwrap();

        assert_eq!(config.listen, "127.0.0.1:0".parse().unwrap());
        assert!(!config.allow_remote);
        assert_eq!(config.public_origin, None);
    }

    #[test]
    fn remote_proxy_local_accepts_loopback_ephemeral_port() {
        let settings = ServerExposureSettingsDto {
            mode: ServerExposureMode::RemoteProxyLocal,
            auto_start: false,
            port: 0,
            public_origin: Some("https://idea.example.com".to_owned()),
            trusted_proxies: Vec::new(),
            lan_bind_address: None,
        };

        let config =
            server_config_from_settings(&settings, tmp_app_data(), tmp_app_data()).unwrap();

        assert_eq!(config.listen, "127.0.0.1:0".parse().unwrap());
        assert!(config.allow_remote);
        assert!(config.trusted_proxies.is_empty());
    }

    #[test]
    fn store_round_trips_without_pairing_code() {
        let store = FsServerExposureSettingsStore::new(tmp_app_data());
        let settings = ServerExposureSettingsDto {
            mode: ServerExposureMode::RemoteProxyOtherMachine,
            auto_start: false,
            port: 17373,
            public_origin: Some("https://idea.example.com".to_owned()),
            trusted_proxies: vec!["192.0.2.22".to_owned()],
            lan_bind_address: Some("192.0.2.75".to_owned()),
        };

        store.write(&settings).unwrap();
        let read = store.read().unwrap();

        assert_eq!(read, settings);
    }

    #[tokio::test]
    async fn stop_is_idempotent_when_already_stopped() {
        let controller = EmbeddedServerController::new(tmp_app_data());

        let first = controller.stop().await.unwrap();
        let second = controller.stop().await.unwrap();

        assert!(matches!(first.state, EmbeddedServerStatusStateDto::Stopped));
        assert!(matches!(
            second.state,
            EmbeddedServerStatusStateDto::Stopped
        ));
    }

    #[tokio::test]
    async fn start_is_idempotent_and_stop_stops_running_server() {
        if !loopback_bind_available() {
            return;
        }
        let app_data = tmp_app_data();
        let resource_dir = tmp_resource_dir_with_web_root();
        let controller =
            EmbeddedServerController::with_resource_dir(app_data.clone(), Some(resource_dir));
        controller
            .save_settings(ServerExposureSettingsDto {
                mode: ServerExposureMode::LocalOnly,
                auto_start: false,
                port: 0,
                public_origin: None,
                trusted_proxies: Vec::new(),
                lan_bind_address: None,
            })
            .unwrap();
        let core = Arc::new(BackendCore::build(app_data));

        let first = controller.start(Arc::clone(&core)).await.unwrap();
        let second = controller.start(core).await.unwrap();

        assert!(matches!(first.state, EmbeddedServerStatusStateDto::Running));
        assert!(matches!(
            second.state,
            EmbeddedServerStatusStateDto::Running
        ));
        assert_eq!(first.local_url, second.local_url);
        assert!(first
            .local_url
            .as_deref()
            .is_some_and(|url| url.starts_with("http://127.0.0.1:")));
        assert_ne!(first.local_url.as_deref(), Some("http://127.0.0.1:0"));
        let pairing = controller.generate_pairing_code().unwrap();
        assert_eq!(pairing.ttl_seconds, 600);
        assert_eq!(pairing.code.len(), 8);

        let stopped = controller.stop().await.unwrap();

        assert!(matches!(
            stopped.state,
            EmbeddedServerStatusStateDto::Stopped
        ));
        assert!(stopped.local_url.is_none());
    }

    #[tokio::test]
    async fn start_with_invalid_persisted_settings_fails_closed() {
        let app_data = tmp_app_data();
        let controller = EmbeddedServerController::new(app_data.clone());
        let bad = ServerExposureSettingsDto {
            mode: ServerExposureMode::RemoteProxyLocal,
            auto_start: false,
            port: 17373,
            public_origin: Some("http://idea.example.com".to_owned()),
            trusted_proxies: Vec::new(),
            lan_bind_address: None,
        };
        let path = app_data.join("deployment").join("server-exposure.json");
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(&path, serde_json::to_vec(&bad).unwrap()).unwrap();
        let core = Arc::new(BackendCore::build(app_data));

        let err = controller.start(core).await.unwrap_err();
        let status = controller.status();

        assert_eq!(err.code, "INVALID");
        assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed));
    }

    #[test]
    fn legacy_settings_without_auto_start_default_to_disabled() {
        let app_data = tmp_app_data();
        let path = app_data.join("deployment").join("server-exposure.json");
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(
            &path,
            r#"{"mode":"localOnly","port":17373,"publicOrigin":null,"trustedProxies":[],"lanBindAddress":null}"#,
        )
        .unwrap();
        let store = FsServerExposureSettingsStore::new(app_data);

        let settings = store.read().unwrap();

        assert!(!settings.auto_start);
    }

    #[tokio::test]
    async fn auto_start_disabled_does_not_start() {
        let app_data = tmp_app_data();
        let controller = EmbeddedServerController::new(app_data.clone());
        controller
            .save_settings(ServerExposureSettingsDto {
                mode: ServerExposureMode::LocalOnly,
                auto_start: false,
                port: 0,
                public_origin: None,
                trusted_proxies: Vec::new(),
                lan_bind_address: None,
            })
            .unwrap();
        let core = Arc::new(BackendCore::build(app_data));

        let status = controller.auto_start_if_enabled(core).await.unwrap();

        assert!(status.is_none());
        assert!(matches!(
            controller.status().state,
            EmbeddedServerStatusStateDto::Stopped
        ));
    }

    #[tokio::test]
    async fn auto_start_enabled_starts_server() {
        if !loopback_bind_available() {
            return;
        }
        let app_data = tmp_app_data();
        let resource_dir = tmp_resource_dir_with_web_root();
        let controller =
            EmbeddedServerController::with_resource_dir(app_data.clone(), Some(resource_dir));
        controller
            .save_settings(ServerExposureSettingsDto {
                mode: ServerExposureMode::LocalOnly,
                auto_start: true,
                port: 0,
                public_origin: None,
                trusted_proxies: Vec::new(),
                lan_bind_address: None,
            })
            .unwrap();
        let core = Arc::new(BackendCore::build(app_data));

        let status = controller
            .auto_start_if_enabled(core)
            .await
            .unwrap()
            .expect("auto-start should start");

        assert!(matches!(
            status.state,
            EmbeddedServerStatusStateDto::Running
        ));
        assert!(status.local_url.is_some());
        controller.stop().await.unwrap();
    }

    #[tokio::test]
    async fn auto_start_enabled_with_invalid_config_marks_failed() {
        let app_data = tmp_app_data();
        let controller = EmbeddedServerController::new(app_data.clone());
        let path = app_data.join("deployment").join("server-exposure.json");
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(
            &path,
            r#"{"mode":"remoteProxyLocal","autoStart":true,"port":17373,"publicOrigin":"http://idea.example.com","trustedProxies":[],"lanBindAddress":null}"#,
        )
        .unwrap();
        let core = Arc::new(BackendCore::build(app_data));

        let err = controller.auto_start_if_enabled(core).await.unwrap_err();
        let status = controller.status();

        assert_eq!(err.code, "INVALID");
        assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed));
        assert_eq!(
            status.error.as_ref().map(|err| err.code.as_str()),
            Some("INVALID")
        );
    }

    #[tokio::test]
    async fn stop_does_not_modify_auto_start_setting() {
        if !loopback_bind_available() {
            return;
        }
        let app_data = tmp_app_data();
        let resource_dir = tmp_resource_dir_with_web_root();
        let controller =
            EmbeddedServerController::with_resource_dir(app_data.clone(), Some(resource_dir));
        controller
            .save_settings(ServerExposureSettingsDto {
                mode: ServerExposureMode::LocalOnly,
                auto_start: true,
                port: 0,
                public_origin: None,
                trusted_proxies: Vec::new(),
                lan_bind_address: None,
            })
            .unwrap();
        let core = Arc::new(BackendCore::build(app_data));
        controller.start(core).await.unwrap();

        controller.stop().await.unwrap();

        assert!(controller.get_settings().unwrap().auto_start);
    }

    #[tokio::test]
    async fn start_port_collision_returns_actionable_error_code() {
        if !loopback_bind_available() {
            return;
        }
        let app_data = tmp_app_data();
        let resource_dir = tmp_resource_dir_with_web_root();
        let reserved = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
        let port = reserved.local_addr().unwrap().port();
        let controller =
            EmbeddedServerController::with_resource_dir(app_data.clone(), Some(resource_dir));
        controller
            .save_settings(ServerExposureSettingsDto {
                mode: ServerExposureMode::LocalOnly,
                auto_start: false,
                port,
                public_origin: None,
                trusted_proxies: Vec::new(),
                lan_bind_address: None,
            })
            .unwrap();
        let core = Arc::new(BackendCore::build(app_data));

        let err = controller.start(core).await.unwrap_err();
        let status = controller.status();

        assert_eq!(err.code, "PORT_IN_USE");
        assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed));
        assert_eq!(
            status.error.as_ref().map(|err| err.code.as_str()),
            Some("PORT_IN_USE")
        );
    }
}
