//! Opt-in discovery posture (W7 P6) — the pure policy that turns a (network mode,
//! "want to be Discoverable?") pair into *which* n0 DNS address-lookup services to
//! run, plus the Discoverable time-box. The actual iroh wiring (clearing and
//! reinstalling the endpoint's address-lookup services) lives in `core`; this module
//! is the small, testable decision surface so the policy can be unit-tested without
//! standing up an endpoint.
//!
//! The model (from `docs/contacts-plan.md` P6, decided 2026-06-16):
//! - **Resolving is always allowed on relay-capable modes** — a stationary friend
//!   (typically in `Normal`) must be able to look up a friend who moved networks. A
//!   resolve is a DNS query to n0 that publishes nothing, but still exposes query
//!   timing/source metadata to n0; it only fires when a saved address is stale and
//!   the dial falls through to discovery.
//! - **Publishing is gated on `Discoverable`** and asymmetric: only the mover
//!   publishes their address to n0 DNS; everyone else just looks it up.
//! - **Stopping publishing removes the local publisher service**; iroh does not
//!   expose an explicit unpublish call here, so already-published pkarr records can
//!   linger until their default ~30s TTL expires.
//! - **`DirectOnly` is the explicit no-server posture** — neither resolve nor publish
//!   ever touches n0 there, regardless of the Discoverable toggle.

use crate::config::NetworkMode;
use crate::presence::PresenceMode;
use std::time::Duration;

/// How long `Discoverable` stays on before auto-reverting to `Normal`. Discovery is
/// a publish-to-n0 beacon, so it auto-expires rather than standing indefinitely; the
/// user can re-toggle to restart the clock. (Open decision #3 settled: 30-minute
/// time-box, not sticky-until-off.)
pub const DISCOVERY_TIMEBOX: Duration = Duration::from_secs(30 * 60);

/// Which n0 DNS address-lookup services to install for a posture. The in-memory
/// lookup (fed by tickets + gossip, fully server-free) is always present and is
/// deliberately *not* represented here — these flags are only about n0 DNS.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LookupPlan {
    /// Resolve a moved friend's address by their (stable) node id via n0 DNS. A
    /// passive query — publishes nothing — and only fires when a saved address is
    /// stale enough that the dial falls through to discovery.
    pub resolver: bool,
    /// Publish *our* current address to n0 DNS so friends can find us after we moved
    /// networks. The one privacy-costly bit; on only while `Discoverable`.
    pub publisher: bool,
}

/// The n0 DNS services wanted for a `(network_mode, want_publish)` pair. Pure.
///
/// `want_publish` is `true` exactly when the presence posture is `Discoverable`
/// (see [`crate::presence::PresenceMode::publishes_to_discovery`]).
pub fn lookup_plan(network_mode: NetworkMode, want_publish: bool) -> LookupPlan {
    match network_mode {
        // The explicit serverless posture: no n0 contact at all, even to resolve.
        // A Discoverable toggle here is intentionally inert.
        NetworkMode::DirectOnly => LookupPlan {
            resolver: false,
            publisher: false,
        },
        // Relay-capable: always resolve (so a stationary friend can find a mover);
        // publish only when the user opted into Discoverable.
        NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => LookupPlan {
            resolver: true,
            publisher: want_publish,
        },
    }
}

/// Decide which presence mode may be committed after attempting to apply discovery
/// services for `requested`.
///
/// On failure, keep the previous mode: it is the only locally truthful state because
/// the endpoint's discovery services may still reflect the old posture. Same-mode
/// requests are no-ops from a presence-truth perspective and do not surface an error.
pub fn resolve_presence_transition(
    previous: PresenceMode,
    requested: PresenceMode,
    apply_ok: bool,
) -> (PresenceMode, Option<String>) {
    if previous == requested {
        return (previous, None);
    }

    if apply_ok {
        (requested, None)
    } else {
        (
            previous,
            Some(format!(
                "Couldn't update discovery mode; keeping {previous}."
            )),
        )
    }
}

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

    #[test]
    fn relay_modes_always_resolve_and_publish_only_when_wanted() {
        for mode in [NetworkMode::RelayNoDiscovery, NetworkMode::N0Full] {
            assert_eq!(
                lookup_plan(mode, false),
                LookupPlan {
                    resolver: true,
                    publisher: false
                },
                "{mode:?}: resolve always on, no publish when not Discoverable"
            );
            assert_eq!(
                lookup_plan(mode, true),
                LookupPlan {
                    resolver: true,
                    publisher: true
                },
                "{mode:?}: Discoverable adds publish on top of resolve"
            );
        }
    }

    #[test]
    fn direct_only_never_touches_n0_even_when_discoverable() {
        assert_eq!(
            lookup_plan(NetworkMode::DirectOnly, false),
            LookupPlan {
                resolver: false,
                publisher: false
            }
        );
        // The serverless posture overrides the Discoverable request entirely.
        assert_eq!(
            lookup_plan(NetworkMode::DirectOnly, true),
            LookupPlan {
                resolver: false,
                publisher: false
            }
        );
    }

    #[test]
    fn timebox_is_thirty_minutes() {
        assert_eq!(DISCOVERY_TIMEBOX, Duration::from_secs(1800));
    }

    #[test]
    fn presence_transition_commits_requested_mode_after_successful_apply() {
        assert_eq!(
            resolve_presence_transition(PresenceMode::Normal, PresenceMode::Discoverable, true),
            (PresenceMode::Discoverable, None)
        );
    }

    #[test]
    fn presence_transition_keeps_previous_mode_when_apply_fails() {
        let (mode, err) =
            resolve_presence_transition(PresenceMode::Normal, PresenceMode::Discoverable, false);

        assert_eq!(mode, PresenceMode::Normal);
        assert!(err.unwrap().contains("keeping Normal"));
    }

    #[test]
    fn presence_transition_keeps_discoverable_when_off_transition_fails() {
        let (mode, err) =
            resolve_presence_transition(PresenceMode::Discoverable, PresenceMode::Normal, false);

        assert_eq!(mode, PresenceMode::Discoverable);
        assert!(err.unwrap().contains("keeping Discoverable"));
    }

    #[test]
    fn presence_transition_same_mode_is_noop_without_error() {
        assert_eq!(
            resolve_presence_transition(
                PresenceMode::Discoverable,
                PresenceMode::Discoverable,
                false
            ),
            (PresenceMode::Discoverable, None)
        );
    }
}
