// Every item in this module is `#[cfg(target_os = "linux")]`, so gate the
// imports too — otherwise they are unused on macOS/Windows builds.
#[cfg(target_os = "linux")]
use anyhow::{Context, Result};
#[cfg(target_os = "linux")]
use std::path::Path;

/// Path to the system-wide systemd service file.
#[cfg(target_os = "linux")]
const SYSTEM_SERVICE_PATH: &str = "/etc/systemd/system/freenet.service";

/// Check if a system-wide Freenet service is installed.
#[cfg(target_os = "linux")]
fn has_system_service() -> bool {
    Path::new(SYSTEM_SERVICE_PATH).exists()
}

/// Check if a user-level Freenet service is installed.
#[cfg(target_os = "linux")]
fn has_user_service() -> bool {
    dirs::home_dir()
        .map(|h| h.join(".config/systemd/user/freenet.service").exists())
        .unwrap_or(false)
}

/// Recursively chown a directory to the given user (best-effort).
/// Used after creating directories with sudo so the service user can write to them.
#[cfg(target_os = "linux")]
fn chown_to_user(path: &Path, username: &str) {
    let _status = std::process::Command::new("chown")
        .args(["-R", username, &path.display().to_string()])
        .status();
}

/// Look up a user's home directory from /etc/passwd via `getent passwd`.
/// Falls back to `/home/{username}` if getent is unavailable.
#[cfg(target_os = "linux")]
pub(super) fn home_dir_for_user(username: &str) -> std::path::PathBuf {
    // Try getent passwd which works with NSS (LDAP, NIS, etc.)
    if let Ok(output) = std::process::Command::new("getent")
        .args(["passwd", username])
        .output()
    {
        if output.status.success() {
            let line = String::from_utf8_lossy(&output.stdout);
            // Format: username:x:uid:gid:gecos:home:shell
            if let Some(home) = line.split(':').nth(5) {
                let home = home.trim();
                if !home.is_empty() {
                    return std::path::PathBuf::from(home);
                }
            }
        }
    }
    std::path::PathBuf::from(format!("/home/{username}"))
}

/// Resolve whether to use system or user mode.
/// If `--system` is passed, use system mode. Otherwise auto-detect based on
/// which service file exists, defaulting to user mode.
#[cfg(target_os = "linux")]
fn use_system_mode(system_flag: bool) -> bool {
    // Auto-detect: if only system service exists, use system mode
    system_flag || (has_system_service() && !has_user_service())
}

/// Run a systemctl command, using --user or not based on system mode.
#[cfg(target_os = "linux")]
fn systemctl(system_mode: bool, args: &[&str]) -> Result<std::process::ExitStatus> {
    let mut cmd = std::process::Command::new("systemctl");
    if !system_mode {
        cmd.arg("--user");
    }
    cmd.args(args);
    let status = cmd.status().context("Failed to run systemctl")?;
    Ok(status)
}

/// Run a systemctl command with helpful error on user-session failures.
#[cfg(target_os = "linux")]
fn systemctl_with_hint(system_mode: bool, args: &[&str], action: &str) -> Result<()> {
    let status = systemctl(system_mode, args)?;
    if status.success() {
        return Ok(());
    }

    if system_mode {
        anyhow::bail!("Failed to {action}");
    }

    // Check if this looks like a user session bus issue
    let hint = std::process::Command::new("systemctl")
        .args(["--user", "daemon-reload"])
        .stderr(std::process::Stdio::piped())
        .output()
        .ok()
        .and_then(|out| {
            let stderr = String::from_utf8_lossy(&out.stderr);
            if stderr.contains("bus")
                || stderr.contains("XDG_RUNTIME_DIR")
                || stderr.contains("Failed to connect")
            {
                Some(
                    "\n\nHint: User systemd session not available (common in containers/LXC).\n\
                     Try: sudo freenet service install --system",
                )
            } else {
                None
            }
        })
        .unwrap_or("");

    anyhow::bail!("Failed to {action}{hint}");
}

#[cfg(target_os = "linux")]
pub(super) fn install_service(system: bool, no_linger: bool) -> Result<()> {
    if system {
        // A system service runs at boot independent of any login session, so
        // lingering does not apply to it; `no_linger` is ignored here.
        install_system_service()
    } else {
        install_user_service(no_linger)
    }
}

/// The lingering action implied by the install flags. Pure decision split out
/// from the side-effecting install so the policy can be unit-tested:
/// a system service never lingers (it starts at boot); a user service enables
/// lingering by default and skips it only on `--no-linger`.
#[cfg(target_os = "linux")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LingerAction {
    /// System service: lingering is irrelevant (runs at boot regardless).
    SystemService,
    /// User service: enable lingering so it survives logout (the default).
    EnableForUser,
    /// User service with `--no-linger`: keep the service login-scoped.
    SkipForUser,
}

#[cfg(target_os = "linux")]
fn linger_action(system: bool, no_linger: bool) -> LingerAction {
    if system {
        LingerAction::SystemService
    } else if no_linger {
        LingerAction::SkipForUser
    } else {
        LingerAction::EnableForUser
    }
}

/// Resolve the current login name, for enabling systemd lingering on a user
/// service. Prefers `$USER`/`$LOGNAME`, falling back to `id -un`.
#[cfg(target_os = "linux")]
fn current_username() -> Option<String> {
    for var in ["USER", "LOGNAME"] {
        if let Ok(value) = std::env::var(var) {
            let value = value.trim();
            if !value.is_empty() {
                return Some(value.to_string());
            }
        }
    }
    std::process::Command::new("id")
        .arg("-un")
        .output()
        .ok()
        .filter(|out| out.status.success())
        .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
        .filter(|name| !name.is_empty())
}

/// Whether systemd lingering is already enabled for `username`.
#[cfg(target_os = "linux")]
fn linger_enabled(username: &str) -> bool {
    std::process::Command::new("loginctl")
        .args(["show-user", username, "--property=Linger", "--value"])
        .output()
        .ok()
        .filter(|out| out.status.success())
        .map(|out| String::from_utf8_lossy(&out.stdout).trim() == "yes")
        .unwrap_or(false)
}

/// Enable systemd "lingering" for `username` so their `--user` services keep
/// running without an active login session.
///
/// This is ESSENTIAL for a headless server: without lingering, a `--user`
/// service is bound to the user's login session and is stopped at logout, so
/// it never restarts on boot and never catches the node's exit-42 "update
/// needed" signal — the node silently stops auto-updating. Enabling lingering
/// is the user-service counterpart to a `--system` service starting at boot.
///
/// Best-effort: a failure only weakens auto-update across logout, so we warn
/// (with a remediation hint) rather than aborting the install. Idempotent: a
/// no-op when lingering is already enabled.
#[cfg(target_os = "linux")]
fn enable_linger(username: &str) {
    if linger_enabled(username) {
        println!("systemd lingering already enabled for user '{username}'.");
        return;
    }
    match std::process::Command::new("loginctl")
        .args(["enable-linger", username])
        .status()
    {
        Ok(status) if status.success() => {
            println!(
                "Enabled systemd lingering for user '{username}' \
                 (the service runs without an active login session, so it \
                 auto-updates on a headless server)."
            );
        }
        _ => {
            eprintln!(
                "Warning: could not enable systemd lingering for user '{username}'.\n\
                 The user service will run ONLY while you are logged in, so it will\n\
                 NOT auto-update on a headless server. Enable it manually with:\n  \
                 sudo loginctl enable-linger {username}\n\
                 Or reinstall as a system service:\n  \
                 sudo freenet service install --system"
            );
        }
    }
}

#[cfg(target_os = "linux")]
fn install_user_service(no_linger: bool) -> Result<()> {
    use std::fs;

    let exe_path = std::env::current_exe().context("Failed to get current executable path")?;
    let home_dir = dirs::home_dir().context("Failed to get home directory")?;

    let service_dir = home_dir.join(".config/systemd/user");
    fs::create_dir_all(&service_dir).context("Failed to create systemd user directory")?;

    // Create log directory - use ~/.local/state/freenet for XDG compliance
    let log_dir = home_dir.join(".local/state/freenet");
    fs::create_dir_all(&log_dir).context("Failed to create log directory")?;

    let service_content = generate_user_service_file(&exe_path, &log_dir);
    let service_path = service_dir.join("freenet.service");

    fs::write(&service_path, &service_content).context("Failed to write service file")?;

    // Sidecar records the unit's SHA-256 so a later `freenet update` can
    // distinguish "Freenet's unit" from a hand-edited one before
    // overwriting (#4287). A failed sidecar write only weakens future
    // user-modification protection — warn and continue.
    let hash_path = service_path.with_extension("service.hash");
    let unit_hash = super::super::update::wrapper_content_hash(&service_content);
    if let Err(e) = super::super::update::write_wrapper_hash_sidecar(&hash_path, &unit_hash) {
        eprintln!(
            "Warning: failed to write service hash sidecar at {}: {}.",
            hash_path.display(),
            e
        );
    }

    // Reload systemd user daemon
    systemctl_with_hint(false, &["daemon-reload"], "reload systemd daemon")?;

    // Enable the service
    systemctl_with_hint(false, &["enable", "freenet"], "enable service")?;

    // Enable lingering by default so the service runs without an active login
    // session (the headless-server auto-update requirement). Opt out with
    // `--no-linger` to keep the service login-scoped.
    //
    // install_user_service is only reached for a user install, so the
    // SystemService arm is unreachable here, but matching exhaustively keeps
    // the policy in one place.
    let action = linger_action(false, no_linger);
    let username = current_username();
    let lingering = match action {
        LingerAction::EnableForUser => match &username {
            Some(user) => {
                enable_linger(user);
                linger_enabled(user)
            }
            None => {
                eprintln!(
                    "Warning: could not determine the current username to enable \
                     systemd lingering. The user service will run only while you are \
                     logged in (no auto-update across logout). Enable it manually with:\n  \
                     sudo loginctl enable-linger <your-user>"
                );
                false
            }
        },
        LingerAction::SkipForUser | LingerAction::SystemService => false,
    };

    let user_label = username.as_deref().unwrap_or("<your-user>");

    println!("Freenet user service installed successfully.");
    println!();
    println!("To start the service now:");
    println!("  freenet service start");
    println!();
    if lingering {
        println!("The service will start automatically on login and on boot");
        println!("(systemd lingering is enabled, so it keeps running when you log out).");
    } else if matches!(action, LingerAction::SkipForUser) {
        // The operator explicitly opted out, so don't nag with a remediation;
        // just state the consequence plainly.
        println!("The service will start automatically on login.");
        println!("Lingering is disabled (--no-linger): the service stops at logout, so it");
        println!("will not auto-update on a headless server. Re-enable it any time with:");
        println!("  sudo loginctl enable-linger {user_label}");
    } else {
        println!("The service will start automatically on login.");
        println!("NOTE: lingering could NOT be enabled, so the service stops at logout and");
        println!("      will not auto-update on a headless server. Enable it with:");
        println!("        sudo loginctl enable-linger {user_label}");
    }
    println!("Logs will be written to: {}", log_dir.display());

    Ok(())
}

#[cfg(target_os = "linux")]
fn install_system_service() -> Result<()> {
    use std::fs;

    let exe_path = std::env::current_exe().context("Failed to get current executable path")?;

    // Get the user to run the service as.
    // When running with sudo, SUDO_USER has the original (non-root) user.
    let username = std::env::var("SUDO_USER")
        .or_else(|_| std::env::var("USER"))
        .or_else(|_| std::env::var("LOGNAME"))
        .context(
            "Could not determine username. Set the USER environment variable \
             or run with sudo (which sets SUDO_USER).",
        )?;

    if username == "root" {
        anyhow::bail!(
            "Refusing to install system service running as root.\n\
             Run with sudo from a non-root user account so SUDO_USER is set,\n\
             or set the USER environment variable to the desired service user."
        );
    }

    // Look up the user's home directory from /etc/passwd.
    // When running with sudo, dirs::home_dir() returns /root which is wrong.
    let home_dir = home_dir_for_user(&username);

    // Create log directory and fix ownership (we're running as root via sudo,
    // but the service will run as the target user).
    let log_dir = home_dir.join(".local/state/freenet");
    fs::create_dir_all(&log_dir).context("Failed to create log directory")?;
    chown_to_user(&log_dir, &username);

    let service_content = generate_system_service_file(&exe_path, &log_dir, &username, &home_dir);

    fs::write(SYSTEM_SERVICE_PATH, &service_content).with_context(|| {
        format!(
            "Failed to write service file to {SYSTEM_SERVICE_PATH}. \
             Are you running as root? Try: sudo freenet service install --system"
        )
    })?;

    // Sidecar records the unit's SHA-256 so a later `freenet update` can
    // distinguish "Freenet's unit" from a hand-edited one before
    // overwriting (#4287). Lives next to the root-owned unit and is
    // written by the same root process, so root owns it too. A failed
    // sidecar write only weakens future user-modification protection —
    // warn and continue.
    let system_hash_path = Path::new(SYSTEM_SERVICE_PATH).with_extension("service.hash");
    let unit_hash = super::super::update::wrapper_content_hash(&service_content);
    if let Err(e) = super::super::update::write_wrapper_hash_sidecar(&system_hash_path, &unit_hash)
    {
        eprintln!(
            "Warning: failed to write service hash sidecar at {}: {}.",
            system_hash_path.display(),
            e
        );
    }

    // Reload systemd daemon (system-level, no --user)
    let status = systemctl(true, &["daemon-reload"])?;
    if !status.success() {
        anyhow::bail!("Failed to reload systemd daemon");
    }

    // Enable the service
    let status = systemctl(true, &["enable", "freenet"])?;
    if !status.success() {
        anyhow::bail!("Failed to enable service");
    }

    println!("Freenet system service installed successfully.");
    println!("  Service runs as user: {username}");
    println!();
    println!("To start the service now:");
    println!("  sudo freenet service start --system");
    println!();
    println!("The service will start automatically on boot.");
    println!("Logs will be written to: {}", log_dir.display());

    Ok(())
}

#[cfg(target_os = "linux")]
pub fn generate_user_service_file(binary_path: &Path, log_dir: &Path) -> String {
    format!(
        r#"[Unit]
Description=Freenet Node
Documentation=https://freenet.org
After=network-online.target
Wants=network-online.target
# Crash-loop rate limiting (issue #4551). StartLimitBurst / StartLimitIntervalSec
# are [Unit]-section directives (#4570 moved them here from [Service], where systemd
# SILENTLY IGNORES them — which had disabled the limiter entirely, so with
# Restart=always + RestartSec=10 a crashing node restarted every ~10s forever).
#
# Only a RAPID loop trips this: more than 5 starts within 120s. A legitimate
# single restart (auto-update, an occasional reboot, a one-off crash) is nowhere
# near 5-in-120s and is never penalised. A boot-wedge / fast crash exits 45 (a
# counted failure — see SuccessExitStatus in [Service]), so a tight no-fix loop of
# those trips the limiter and the unit stops; a node that ran healthily first
# exits the burst-exempt code 42 (see crate::node::p2p_impl). BOTH 42 and 45 still
# fire the ExecStopPost `freenet update` self-heal in [Service], so a boot-crash
# that a newer release fixes recovers even though exit 45 is counted.
#
# StartLimitAction=none (the default, made explicit) STOPS the unit on trip (it
# enters "failed") rather than rebooting the host — the desired terminal state
# for a crash loop: better than looping forever, and loud to the operator.
# Recover with `systemctl reset-failed freenet && systemctl start freenet` (the
# lockout also clears on the next reboot / `freenet update`).
StartLimitBurst=5
StartLimitIntervalSec=120
StartLimitAction=none

[Service]
Type=simple
# Mark the node as supervised (issue #4580): tells `freenet network` that exit
# code 42 (update needed) will be applied by ExecStopPost below, so it logs an
# informational message instead of a loud "no supervisor" error on update exit.
# systemd also sets INVOCATION_ID for every service, which the node detects as a
# fallback; this makes the intent explicit and robust.
Environment=FREENET_SUPERVISED=1
# #4551: advertise to the node that THIS unit understands the fast-crash exit code
# 45 — it keeps 45 out of SuccessExitStatus (so 45 counts toward StartLimitBurst),
# sets StartLimitAction=none, and runs `freenet update` on exit 42 OR 45. The node
# emits 45 only when it sees this marker, so a binary running under an OLD unit
# (auto-updated but not reinstalled) or the mac/Windows wrapper keeps emitting the
# self-healing exit 42 instead of a 45 the supervisor would mishandle.
Environment={fast_crash_marker}=1
# Stale-orphan self-heal (issue #3967): RestartPreventExitStatus=43 below
# means an exit 43 ("another instance already running") never restarts the
# unit. That is correct for a legitimate second instance, but if the port
# holder is an ORPHANED `freenet network` (PPID=1) still running an OLD
# binary, the unit would stand down and the orphan would serve stale assets
# forever. This pre-flight runs before every start: it finds the port
# holder, and kills it ONLY when it is an init-adopted orphan (PPID==1) whose
# `Freenet version:` line differs from the binary this unit would launch (or
# whose version can't be read). A user-run `freenet network` (parented by a
# shell, PPID!=1) is always left alone, as is a current-version orphan.
#
# systemd performs its own $VAR/${{VAR}} expansion on Exec* lines BEFORE handing
# the string to /bin/sh, so every dollar the SHELL must see is written as $$
# here (systemd collapses $$ -> a single $ for sh). Self-match guards: the
# pre-flight sh's OWN argv contains the literal "freenet network" (it is the
# substring `pgrep -f` matches), so the pre-flight excludes its own PID ($$$$ ->
# the sh's $$) and PID 1 from the holder loop. We deliberately do NOT anchor on
# the holder's exe equalling THIS unit's on-disk binary: a #3967 orphan is, by
# definition, running an OLD/DIFFERENT binary, so an `exe == on-disk binary`
# guard would skip exactly the orphan we must kill. The PPID==1 + version-line
# checks below are what distinguish a stale orphan from a legitimate holder.
# PPID is read after the final ')' in /proc/PID/stat (comm is parenthesized) so
# a comm containing whitespace can't shift the field. The '-' prefix means a
# failure here never blocks the start.
ExecStartPre=-/bin/sh -c 'self=$$$$; ondisk=$$(timeout 5 {binary} --version 2>/dev/null | grep "^Freenet version:"); for pid in $$(pgrep -f -u "$$(id -u)" "freenet network" 2>/dev/null); do [ "$$pid" = "$$self" ] && continue; [ "$$pid" = "1" ] && continue; exe=$$(readlink -f /proc/$$pid/exe 2>/dev/null); hv=""; [ -x "$$exe" ] && hv=$$(timeout 5 "$$exe" --version 2>/dev/null | grep "^Freenet version:"); ppid=$$(sed "s/.*) //" /proc/$$pid/stat 2>/dev/null | awk "{{print \$$2}}"); mismatch=1; [ -n "$$ondisk" ] && [ -n "$$hv" ] && [ "$$hv" != "$$ondisk" ] && mismatch=0; if [ "$$ppid" = "1" ] && {{ [ "$$mismatch" = "0" ] || [ -z "$$hv" ]; }}; then kill -TERM "$$pid" 2>/dev/null || true; w=0; while kill -0 "$$pid" 2>/dev/null && [ $$w -lt 12 ]; do sleep 1; w=$$((w+1)); done; kill -0 "$$pid" 2>/dev/null && kill -KILL "$$pid" 2>/dev/null || true; fi; done'
ExecStart={binary} network
Restart=always
# Wait 10 seconds before restart to avoid rapid restart loops. The actual
# crash-loop cap (StartLimit*) lives in the [Unit] section above (#4551).
RestartSec=10
# Restart backoff (#4073): grow the inter-restart delay from RestartSec up to
# RestartMaxDelaySec over RestartSteps restarts, so a residual crash/exit loop
# slows down (=> up to 300s between restarts) instead of reconnecting to the
# gateway every ~10s -- gentler on the gateways, and it keeps the node trying for
# a fix instead of hammering.
#
# DELIBERATE tradeoff with the #4551 crash-loop limiter (StartLimitBurst=5 /
# StartLimitIntervalSec=120, StartLimitAction=none in [Unit]). The growing delay
# changes WHICH crashes reach #4551's terminal "failed-state stop":
#   * A TRUE fast crash (sub-second startup wedge) still clusters its first ~5
#     restarts well inside the 120s window while the delay is still small, so it
#     STILL trips StartLimitBurst and the unit terminal-stops. The #4551
#     brick-loop guarantee is preserved for the case it was designed for.
#   * A MEDIUM-runtime crash (node dies tens of seconds in) can, once the delay
#     has grown toward 300s, be spaced PAST the 120s burst window, so it no
#     longer trips StartLimitBurst and instead slow-flaps at <=300s rather than
#     terminal-stopping. This is accepted: such a node is not a tight brick-loop,
#     the slow cadence is easy on the gateways, and continuing to restart lets a
#     later fixed release (via the ExecStopPost `freenet update` below) heal it.
#     A bad UPDATE that crash-loops is still disarmed within its probation window
#     by the #4073/#4591 auto-rollback regardless of this cadence.
#
# These directives require systemd >= 254; OLDER systemd silently IGNORES unknown
# directives (they are not parse errors), so the unit degrades gracefully to the
# fixed RestartSec=10 above (and #4551's limiter then behaves exactly as before).
RestartSteps=10
RestartMaxDelaySec=300
# Allow 45 seconds for graceful shutdown before SIGKILL.
# The node handles SIGTERM by (1) waiting up to `shutdown-drain-secs`
# (default 30s) for in-flight client PUT/GET/UPDATE/SUBSCRIBE drivers
# to finish, then (2) closing peer connections. The 15s headroom over
# the default drain covers peer-connection teardown + spawn-task
# cleanup. If you raise `shutdown-drain-secs`, raise this in lockstep.
TimeoutStopSec=45

# Auto-update / crash-loop rollback / boot-crash self-heal: run `freenet update`
# before systemd restarts the service on ANY non-graceful exit — every
# $EXIT_STATUS except 0 (graceful) and 43 (another instance already running).
# That covers the voluntary update-needed exit 42, the fast-crash code 45
# (#4551), AND the crash codes the listener watchdog never produces: panics
# (101), SIGABRT/SIGKILL/SIGSEGV (134/137/139, which systemd may pass as signal
# NAMES like ABRT/KILL/SEGV), and early-startup errors (1). `freenet update`
# then either steps forward (42 / a newer release) or, for a crash of a
# freshly-installed version still on probation, counts it and — once it keeps
# crashing — rolls back to the previous binary (#4073). `freenet update` is a
# SEPARATE process that can succeed even when `freenet network` crashes at
# startup, preserving the #4549 self-heal.
# This does NOT exempt crash codes from the limiter: ExecStopPost runs AFTER the
# process exits (the '-' prefix means its own result never affects restart) and
# 45 / 1 / 101 / signals stay OUT of SuccessExitStatus below, so a no-fix tight
# loop still trips StartLimitBurst and the unit stops. A `case` (not `&&`/`||`)
# avoids shell-precedence pitfalls. $$EXIT_STATUS is doubled so systemd passes a
# literal $EXIT_STATUS through to sh. {post_stop_env} forwards that status to
# `freenet update` so #4073 can tell a post-stop restart from a manual update
# and classify the crash; an OLD binary (e.g. one we rolled back TO) ignores the
# unknown env var.
ExecStopPost=-/bin/sh -c 'case "$$EXIT_STATUS" in 0|43) ;; *) {post_stop_env}="$$EXIT_STATUS" {binary} update --quiet ;; esac'
# Exit 42 (auto-update) and 43 (another instance) are clean exits, so they are
# not counted as failures — without this, rapid update cycles (exit 42 →
# ExecStopPost → restart) could exhaust the burst limit and kill the service.
# Exit 45 (fast-crash / boot-wedge, #4551) is deliberately NOT whitelisted here:
# it must COUNT toward StartLimitBurst so a tight crash loop trips the [Unit]
# limiter and the unit stops — even though the ExecStopPost above still runs
# `freenet update` for it. Counting (StartLimit) and self-heal (ExecStopPost) are
# independent: the former bounds a no-fix loop, the latter rescues a boot-crash a
# newer release fixes. (44 is the internal bundle-update-staged code, never
# emitted by `freenet network`.)
SuccessExitStatus=42 43
# Exit code 43 = another instance is already running on the port.
# Do NOT restart — the existing instance is healthy.
RestartPreventExitStatus=43

# Logging
# - The node's tracing layer writes its own size-capped, hourly-rotated
#   logs to {log_dir}/freenet.YYYY-MM-DD-HH.log (LOG_RETENTION_HOURS +
#   LOG_DIR_MAX_BYTES; see crates/core/src/tracing.rs).
# - systemd's StandardOutput/StandardError previously appended to a fixed
#   freenet.log / freenet.error.log that the time-based cleanup never
#   pruned (mtime stayed fresh while the file was being written), so they
#   grew without bound on long-running nodes (issue #4251).
# - Routing both to the journal lets journald handle rotation, and panics
#   or pre-tracing-init output remain queryable via
#   `journalctl --user-unit freenet`.
StandardOutput=journal
StandardError=journal
SyslogIdentifier=freenet

# Resource limits to prevent runaway resource consumption
# File descriptors needed for network connections
LimitNOFILE=65536
# Memory limit (2GB soft limit for user service)
MemoryMax=2G
# CPU quota (200% = 2 cores max)
CPUQuota=200%

[Install]
WantedBy=default.target
"#,
        binary = binary_path.display(),
        log_dir = log_dir.display(),
        fast_crash_marker = super::super::auto_update::SYSTEMD_FAST_CRASH_ENV_VAR,
        post_stop_env = super::super::rollback::POST_STOP_EXIT_CODE_ENV_VAR,
    )
}

#[cfg(target_os = "linux")]
pub fn generate_system_service_file(
    binary_path: &Path,
    log_dir: &Path,
    username: &str,
    home_dir: &Path,
) -> String {
    format!(
        r#"[Unit]
Description=Freenet Node
Documentation=https://freenet.org
After=network-online.target
Wants=network-online.target
# Crash-loop rate limiting (issue #4551) — see the matching comment in the user
# unit. StartLimit* live in [Unit] (#4570); systemd silently ignores them in
# [Service]. Only a rapid loop (>5 starts / 120s) trips; a fast crash exits the
# counted code 45 while a healthy-then-died node exits the burst-exempt 42.
# StartLimitAction=none stops the unit (it does NOT reboot the host) on trip.
StartLimitBurst=5
StartLimitIntervalSec=120
StartLimitAction=none

[Service]
Type=simple
User={username}
Environment=HOME={home}
# Mark the node as supervised (issue #4580): see the matching comment in the
# user unit. Tells `freenet network` that exit code 42 (update needed) will be
# applied by ExecStopPost below, so it logs an informational message instead of
# a loud "no supervisor" error on update exit.
Environment=FREENET_SUPERVISED=1
# #4551 fast-crash-aware marker — see the matching comment in the user unit. The
# node emits exit 45 only when this is present (this unit handles 45); otherwise it
# keeps the self-healing exit 42.
Environment={fast_crash_marker}=1
# Stale-orphan self-heal (issue #3967): see the matching comment in the user
# unit (including the systemd $$-escaping, the PPID-after-final-')' parse, and
# why we do NOT anchor on the holder's exe equalling this unit's on-disk binary
# — a #3967 orphan runs an OLD/DIFFERENT binary, so that anchor would skip the
# very process we must kill). The self-PID and PID-1 skips exclude the
# pre-flight's own sh (whose argv contains the literal "freenet network").
# RestartPreventExitStatus=43 means an exit 43 never restarts the unit, so an
# init-adopted orphan (PPID==1) running an OLD binary would hold the port
# forever. This pre-flight kills the holder ONLY when it is such an orphan whose
# `Freenet version:` differs from (or can't be read against) the binary this
# unit launches; a user-run instance (PPID!=1) is always left alone. The '-'
# prefix means a failure here never blocks the start.
ExecStartPre=-/bin/sh -c 'self=$$$$; ondisk=$$(timeout 5 {binary} --version 2>/dev/null | grep "^Freenet version:"); for pid in $$(pgrep -f -u "$$(id -u)" "freenet network" 2>/dev/null); do [ "$$pid" = "$$self" ] && continue; [ "$$pid" = "1" ] && continue; exe=$$(readlink -f /proc/$$pid/exe 2>/dev/null); hv=""; [ -x "$$exe" ] && hv=$$(timeout 5 "$$exe" --version 2>/dev/null | grep "^Freenet version:"); ppid=$$(sed "s/.*) //" /proc/$$pid/stat 2>/dev/null | awk "{{print \$$2}}"); mismatch=1; [ -n "$$ondisk" ] && [ -n "$$hv" ] && [ "$$hv" != "$$ondisk" ] && mismatch=0; if [ "$$ppid" = "1" ] && {{ [ "$$mismatch" = "0" ] || [ -z "$$hv" ]; }}; then kill -TERM "$$pid" 2>/dev/null || true; w=0; while kill -0 "$$pid" 2>/dev/null && [ $$w -lt 12 ]; do sleep 1; w=$$((w+1)); done; kill -0 "$$pid" 2>/dev/null && kill -KILL "$$pid" 2>/dev/null || true; fi; done'
ExecStart={binary} network
Restart=always
# Wait 10 seconds before restart to avoid rapid restart loops. The actual
# crash-loop cap (StartLimit*) lives in the [Unit] section above (#4551).
RestartSec=10
# Restart backoff (#4073): grow the inter-restart delay from RestartSec up to
# RestartMaxDelaySec over RestartSteps restarts, so a residual crash/exit loop
# slows down (=> up to 300s between restarts) instead of reconnecting to the
# gateway every ~10s -- gentler on the gateways, and it keeps the node trying for
# a fix instead of hammering.
#
# DELIBERATE tradeoff with the #4551 crash-loop limiter (StartLimitBurst=5 /
# StartLimitIntervalSec=120, StartLimitAction=none in [Unit]). The growing delay
# changes WHICH crashes reach #4551's terminal "failed-state stop":
#   * A TRUE fast crash (sub-second startup wedge) still clusters its first ~5
#     restarts well inside the 120s window while the delay is still small, so it
#     STILL trips StartLimitBurst and the unit terminal-stops. The #4551
#     brick-loop guarantee is preserved for the case it was designed for.
#   * A MEDIUM-runtime crash (node dies tens of seconds in) can, once the delay
#     has grown toward 300s, be spaced PAST the 120s burst window, so it no
#     longer trips StartLimitBurst and instead slow-flaps at <=300s rather than
#     terminal-stopping. This is accepted: such a node is not a tight brick-loop,
#     the slow cadence is easy on the gateways, and continuing to restart lets a
#     later fixed release (via the ExecStopPost `freenet update` below) heal it.
#     A bad UPDATE that crash-loops is still disarmed within its probation window
#     by the #4073/#4591 auto-rollback regardless of this cadence.
#
# These directives require systemd >= 254; OLDER systemd silently IGNORES unknown
# directives (they are not parse errors), so the unit degrades gracefully to the
# fixed RestartSec=10 above (and #4551's limiter then behaves exactly as before).
RestartSteps=10
RestartMaxDelaySec=300
# Allow 45 seconds for graceful shutdown before SIGKILL.
# The node handles SIGTERM by (1) waiting up to `shutdown-drain-secs`
# (default 30s) for in-flight client PUT/GET/UPDATE/SUBSCRIBE drivers
# to finish, then (2) closing peer connections. The 15s headroom over
# the default drain covers peer-connection teardown + spawn-task
# cleanup. If you raise `shutdown-drain-secs`, raise this in lockstep.
TimeoutStopSec=45

# Auto-update / crash-loop rollback / boot-crash self-heal: run `freenet update`
# before systemd restarts the service on ANY non-graceful exit — every
# $EXIT_STATUS except 0 (graceful) and 43 (another instance already running).
# That covers the voluntary update-needed exit 42, the fast-crash code 45
# (#4551), AND the crash codes the listener watchdog never produces: panics
# (101), SIGABRT/SIGKILL/SIGSEGV (134/137/139, which systemd may pass as signal
# NAMES like ABRT/KILL/SEGV), and early-startup errors (1). `freenet update`
# then either steps forward (42 / a newer release) or, for a crash of a
# freshly-installed version still on probation, counts it and — once it keeps
# crashing — rolls back to the previous binary (#4073). `freenet update` is a
# SEPARATE process that can succeed even when `freenet network` crashes at
# startup, preserving the #4549 self-heal.
# This does NOT exempt crash codes from the limiter: ExecStopPost runs AFTER the
# process exits (the '-' prefix means its own result never affects restart) and
# 45 / 1 / 101 / signals stay OUT of SuccessExitStatus below, so a no-fix tight
# loop still trips StartLimitBurst and the unit stops. A `case` (not `&&`/`||`)
# avoids shell-precedence pitfalls. $$EXIT_STATUS is doubled so systemd passes a
# literal $EXIT_STATUS through to sh. {post_stop_env} forwards that status to
# `freenet update` so #4073 can tell a post-stop restart from a manual update
# and classify the crash; an OLD binary (e.g. one we rolled back TO) ignores the
# unknown env var.
ExecStopPost=-/bin/sh -c 'case "$$EXIT_STATUS" in 0|43) ;; *) {post_stop_env}="$$EXIT_STATUS" {binary} update --quiet ;; esac'
# Exit 42 (auto-update) and 43 (another instance) are clean exits, so they are
# not counted as failures — without this, rapid update cycles (exit 42 →
# ExecStopPost → restart) could exhaust the burst limit and kill the service.
# Exit 45 (fast-crash / boot-wedge, #4551) is deliberately NOT whitelisted here:
# it must COUNT toward StartLimitBurst so a tight crash loop trips the [Unit]
# limiter and the unit stops — even though the ExecStopPost above still runs
# `freenet update` for it. Counting (StartLimit) and self-heal (ExecStopPost) are
# independent: the former bounds a no-fix loop, the latter rescues a boot-crash a
# newer release fixes. (44 is the internal bundle-update-staged code, never
# emitted by `freenet network`.)
SuccessExitStatus=42 43
# Exit code 43 = another instance is already running on the port.
# Do NOT restart — the existing instance is healthy.
RestartPreventExitStatus=43

# Logging
# - The node's tracing layer writes its own size-capped, hourly-rotated
#   logs to {log_dir}/freenet.YYYY-MM-DD-HH.log (LOG_RETENTION_HOURS +
#   LOG_DIR_MAX_BYTES; see crates/core/src/tracing.rs).
# - systemd's StandardOutput/StandardError previously appended to a fixed
#   freenet.log / freenet.error.log that the time-based cleanup never
#   pruned (mtime stayed fresh while the file was being written), so they
#   grew without bound on long-running nodes (issue #4251).
# - Routing both to the journal lets journald handle rotation, and panics
#   or pre-tracing-init output remain queryable via
#   `journalctl -u freenet`.
StandardOutput=journal
StandardError=journal
SyslogIdentifier=freenet

# Resource limits to prevent runaway resource consumption
# File descriptors needed for network connections
LimitNOFILE=65536
# Memory limit (2GB soft limit)
MemoryMax=2G
# CPU quota (200% = 2 cores max)
CPUQuota=200%

[Install]
WantedBy=multi-user.target
"#,
        binary = binary_path.display(),
        log_dir = log_dir.display(),
        username = username,
        home = home_dir.display(),
        fast_crash_marker = super::super::auto_update::SYSTEMD_FAST_CRASH_ENV_VAR,
        post_stop_env = super::super::rollback::POST_STOP_EXIT_CODE_ENV_VAR,
    )
}

/// Stop, disable, and remove the Freenet service file. Does not purge data.
/// Returns true if a service was found and removed.
#[cfg(target_os = "linux")]
pub fn stop_and_remove_service(system: bool) -> Result<bool> {
    use std::fs;

    let system_mode = use_system_mode(system);

    let service_path = if system_mode {
        std::path::PathBuf::from(SYSTEM_SERVICE_PATH)
    } else {
        dirs::home_dir()
            .context("Failed to get home directory")?
            .join(".config/systemd/user/freenet.service")
    };

    if !service_path.exists() {
        return Ok(false);
    }

    // Stop the service if running (best-effort, may already be stopped)
    let _stop = systemctl(system_mode, &["stop", "freenet"]);

    // Disable the service (best-effort, may already be disabled)
    let _disable = systemctl(system_mode, &["disable", "freenet"]);

    fs::remove_file(&service_path).context("Failed to remove service file")?;

    // Reload systemd (best-effort, failure is non-fatal during uninstall)
    drop(systemctl(system_mode, &["daemon-reload"]));

    Ok(true)
}

#[cfg(target_os = "linux")]
pub(super) fn uninstall_service(system: bool, purge: bool, keep_data: bool) -> Result<()> {
    stop_and_remove_service(system)?;

    println!("Freenet service uninstalled.");

    if super::purge::should_purge(purge, keep_data)? {
        let system_mode = use_system_mode(system);
        super::purge::purge_data_dirs(system_mode)?;
        println!("All Freenet data, config, and logs removed.");
    }

    Ok(())
}

#[cfg(target_os = "linux")]
pub(super) fn service_status(system: bool) -> Result<()> {
    let system_mode = use_system_mode(system);
    let status = systemctl(system_mode, &["status", "freenet"])?;
    std::process::exit(status.code().unwrap_or(1));
}

#[cfg(target_os = "linux")]
pub(super) fn start_service(system: bool) -> Result<()> {
    let system_mode = use_system_mode(system);
    systemctl_with_hint(system_mode, &["start", "freenet"], "start service")?;
    println!("Freenet service started.");
    println!("Open http://127.0.0.1:7509/ in your browser to view your Freenet dashboard.");
    Ok(())
}

#[cfg(target_os = "linux")]
pub(super) fn stop_service(system: bool) -> Result<()> {
    let system_mode = use_system_mode(system);
    systemctl_with_hint(system_mode, &["stop", "freenet"], "stop service")?;
    println!("Freenet service stopped.");
    Ok(())
}

#[cfg(target_os = "linux")]
pub(super) fn restart_service(system: bool) -> Result<()> {
    let system_mode = use_system_mode(system);
    systemctl_with_hint(system_mode, &["restart", "freenet"], "restart service")?;
    println!("Freenet service restarted.");
    println!("Open http://127.0.0.1:7509/ in your browser to view your Freenet dashboard.");
    Ok(())
}

#[cfg(target_os = "linux")]
pub(super) fn service_logs(error_only: bool) -> Result<()> {
    let log_dir = dirs::home_dir()
        .context("Failed to get home directory")?
        .join(".local/state/freenet");

    let base_name = if error_only {
        "freenet.error"
    } else {
        "freenet"
    };

    super::log_utils::tail_with_rotation(&log_dir, base_name)
}

#[cfg(all(test, target_os = "linux"))]
mod tests {
    use std::path::Path;

    use super::{
        LingerAction, generate_system_service_file, generate_user_service_file, linger_action,
    };

    /// Policy pin (issue #4073): a system service never lingers (it starts at
    /// boot regardless), a user service enables lingering by default so it
    /// survives logout on a headless server, and `--no-linger` keeps a user
    /// service login-scoped. Lingering is the user-service counterpart to a
    /// system service's boot start; without it a `--user` node stops at logout
    /// and silently never auto-updates.
    #[test]
    fn linger_action_matches_policy() {
        // System service: `no_linger` is irrelevant.
        assert_eq!(linger_action(true, false), LingerAction::SystemService);
        assert_eq!(linger_action(true, true), LingerAction::SystemService);
        // User service defaults to enabling lingering.
        assert_eq!(linger_action(false, false), LingerAction::EnableForUser);
        // User service with --no-linger stays login-scoped.
        assert_eq!(linger_action(false, true), LingerAction::SkipForUser);
    }

    /// Byte offset of the section header line (a line whose trimmed content is
    /// exactly `[name]`). Matches by LINE, not a naive substring search, so a
    /// bracketed section name mentioned inside a comment (e.g. "...ignored in
    /// `[Service]`" inside the `[Unit]` section, #4551) is never mistaken for the
    /// real section header.
    fn section_header_offset(unit: &str, name: &str) -> usize {
        let header = format!("[{name}]");
        let mut offset = 0usize;
        for line in unit.split_inclusive('\n') {
            if line.trim() == header {
                return offset;
            }
            offset += line.len();
        }
        panic!("unit must contain a {header} section header line");
    }

    /// Body of the `[name]` section: everything between that section's header line
    /// and the next section header line. Header lines are matched by line (a
    /// comment containing a bracketed name is ignored), so this is robust to
    /// `[Service]`/`[Unit]` being referenced inside comments.
    fn section<'a>(unit: &'a str, name: &str) -> &'a str {
        let header = format!("[{name}]");
        let mut offset = 0usize;
        let mut body_start: Option<usize> = None;
        for line in unit.split_inclusive('\n') {
            let trimmed = line.trim();
            match body_start {
                None => {
                    if trimmed == header {
                        body_start = Some(offset + line.len());
                    }
                }
                Some(start) => {
                    if trimmed.starts_with('[') && trimmed.ends_with(']') && !trimmed.contains(' ')
                    {
                        return &unit[start..offset];
                    }
                }
            }
            offset += line.len();
        }
        match body_start {
            Some(start) => &unit[start..],
            None => panic!("unit must contain {header} section"),
        }
    }

    fn assert_start_limit_directives_are_in_unit_section(unit_name: &str, unit: &str) {
        let unit_header = section_header_offset(unit, "Unit");
        let service_header = section_header_offset(unit, "Service");
        let unit_section = section(unit, "Unit");
        let service_section = section(unit, "Service");

        for directive in ["StartLimitBurst=5", "StartLimitIntervalSec=120"] {
            let occurrences = unit.lines().filter(|line| line.trim() == directive).count();
            assert_eq!(
                occurrences, 1,
                "{unit_name} unit must emit exactly one {directive}"
            );

            let directive_offset = unit
                .find(directive)
                .unwrap_or_else(|| panic!("{unit_name} unit must contain {directive}"));
            assert!(
                directive_offset > unit_header && directive_offset < service_header,
                "{directive} must appear after [Unit] and before [Service] in {unit_name} unit"
            );
            assert!(
                unit_section.lines().any(|line| line.trim() == directive),
                "{directive} must be in [Unit] for {unit_name} unit"
            );
            assert!(
                !service_section.lines().any(|line| line.trim() == directive),
                "{directive} must not be in [Service] for {unit_name} unit"
            );
        }
    }

    #[test]
    fn user_unit_places_start_limit_directives_in_unit_section() {
        let unit = generate_user_service_file(
            Path::new("/usr/local/bin/freenet"),
            Path::new("/home/test/.local/state/freenet"),
        );

        assert_start_limit_directives_are_in_unit_section("user", &unit);
    }

    #[test]
    fn system_unit_places_start_limit_directives_in_unit_section() {
        let unit = generate_system_service_file(
            Path::new("/usr/local/bin/freenet"),
            Path::new("/home/test/.local/state/freenet"),
            "testuser",
            Path::new("/home/test"),
        );

        assert_start_limit_directives_are_in_unit_section("system", &unit);
    }

    #[test]
    fn systemd_units_have_restart_backoff() {
        // #4073: both units must add escalating restart backoff
        // (RestartSteps + RestartMaxDelaySec) on top of RestartSec so a residual
        // loop that doesn't trip StartLimitBurst still slows down (fewer gateway
        // reconnects). These are systemd >= 254 directives that older systemd
        // ignores; we only assert they are emitted in [Service].
        let user_unit = generate_user_service_file(
            Path::new("/usr/local/bin/freenet"),
            Path::new("/home/test/.local/state/freenet"),
        );
        let system_unit = generate_system_service_file(
            Path::new("/usr/local/bin/freenet"),
            Path::new("/home/test/.local/state/freenet"),
            "testuser",
            Path::new("/home/test"),
        );
        for (name, unit) in [("user", &user_unit), ("system", &system_unit)] {
            let service = section(unit, "Service");
            assert!(
                service.lines().any(|l| l.trim() == "RestartSec=10"),
                "{name} unit must keep the base RestartSec"
            );
            assert!(
                service.lines().any(|l| l.trim() == "RestartSteps=10"),
                "{name} unit must emit RestartSteps in [Service]"
            );
            assert!(
                service
                    .lines()
                    .any(|l| l.trim() == "RestartMaxDelaySec=300"),
                "{name} unit must emit RestartMaxDelaySec in [Service]"
            );
        }
    }

    #[test]
    fn systemd_units_keep_auto_update_success_exit_status() {
        let user_unit = generate_user_service_file(
            Path::new("/usr/local/bin/freenet"),
            Path::new("/home/test/.local/state/freenet"),
        );
        let system_unit = generate_system_service_file(
            Path::new("/usr/local/bin/freenet"),
            Path::new("/home/test/.local/state/freenet"),
            "testuser",
            Path::new("/home/test"),
        );

        assert!(user_unit.contains("SuccessExitStatus=42"));
        assert!(system_unit.contains("SuccessExitStatus=42"));
    }

    #[test]
    fn systemd_units_pass_node_exit_code_to_post_stop_update() {
        // #4073 crash-loop auto-rollback: ExecStopPost must (a) fire on ANY
        // non-graceful exit (every status except 0 and 43) so panics / signals /
        // early errors are covered, and (b) forward the node's stop status to
        // `freenet update` via the env var so the updater can classify the
        // crash. systemd escapes `$$` to a literal `$`.
        let env = super::super::super::rollback::POST_STOP_EXIT_CODE_ENV_VAR;
        // Broadened guard: skip only 0 and 43; run update (with the status
        // forwarded) for everything else.
        let expected = format!(r#"case "$$EXIT_STATUS" in 0|43) ;; *) {env}="$$EXIT_STATUS""#);
        let user_unit = generate_user_service_file(
            Path::new("/usr/local/bin/freenet"),
            Path::new("/home/test/.local/state/freenet"),
        );
        let system_unit = generate_system_service_file(
            Path::new("/usr/local/bin/freenet"),
            Path::new("/home/test/.local/state/freenet"),
            "testuser",
            Path::new("/home/test"),
        );
        for (name, unit) in [("user", &user_unit), ("system", &system_unit)] {
            assert!(
                unit.contains(&expected),
                "{name} unit must fire the post-stop update on any non-graceful exit and \
                 forward {env} (#4073)"
            );
            // Regression guard: the OLD narrow `42|45)` form must be gone.
            assert!(
                !unit.contains("42|45)"),
                "{name} unit still uses the narrow 42|45 ExecStopPost guard, missing \
                 panic/signal/early-error crashes (#4073 M1)"
            );
        }
    }
}
