//! systemd service-unit generators — Task 9 §F.1 + §F.2.
//!
//! This module is **Linux-only** (`#[cfg(target_os = "linux")]` on the
//! declaring `mod` in `units/mod.rs`).  It exports two pure generators:
//!
//! - [`generate_user_unit`]  — writes a `~/.config/systemd/user/sqryd.service`
//!   suitable for `systemctl --user start sqryd`.
//! - [`generate_system_unit`] — writes an `/etc/systemd/system/sqryd@.service`
//!   template that is instantiated per-user via `systemctl start sqryd@<user>`.
//!
//! Both functions return a `String` containing the full unit-file text.  No OS
//! API calls are made during generation itself; the `users` crate is only
//! consulted in `resolve_system_unit_user` which is called before
//! `generate_system_unit` when the generator is invoked from the
//! `install-systemd-system` subcommand (Task 10 U10).
//!
//! # Design reference
//!
//! `docs/reviews/sqryd-daemon/2026-04-19/task-9-design_iter3_request.md` §F.1–§F.2
//! (iter-1 + iter-2 fixes: m4, m8, n3).
//!
//! # Logging and rotation
//!
//! Under systemd supervision the `NOTIFY_SOCKET` environment variable is set.
//! [`crate::lifecycle::notify::is_under_systemd`] detects this and the
//! `install_tracing` path (Task 9 U5) skips the in-process `RollingSizeAppender`.
//! Both generated unit files embed a comment documenting this contract.

use std::path::PathBuf;

use super::InstallOptions;
use crate::config::DaemonConfig;

// ---------------------------------------------------------------------------
// Version stamp helper.
// ---------------------------------------------------------------------------

/// The sqryd binary version, embedded as a leading comment in every generated
/// unit file so operators can correlate unit files with the binary version that
/// produced them.
const SQRYD_VERSION: &str = env!("CARGO_PKG_VERSION");

// ---------------------------------------------------------------------------
// Public API.
// ---------------------------------------------------------------------------

/// Generate a **systemd user unit** (`sqryd.service`) as a `String`.
///
/// The returned text is suitable for installation at one of:
/// - `$XDG_CONFIG_HOME/systemd/user/sqryd.service`
/// - `~/.config/systemd/user/sqryd.service`
///
/// After writing the file, enable with:
/// ```text
/// systemctl --user daemon-reload
/// systemctl --user enable --now sqryd
/// ```
///
/// # `Type=notify`
///
/// The unit uses `Type=notify` together with `NotifyAccess=main`.  The daemon
/// calls [`crate::lifecycle::notify::notify_ready`] (wrapping
/// `sd_notify::notify(&[NotifyState::Ready])`) after `IpcServer::bind`
/// succeeds.  This causes `systemctl --user start` to block until the daemon
/// is fully ready, matching the authoritative-ready-signal matrix in §C.3.1
/// step 15.
///
/// # In-process log rotation
///
/// When `NOTIFY_SOCKET` is set (i.e. running under systemd) the `install_tracing`
/// path skips `RollingSizeAppender` and emits to stdout/stderr, which systemd
/// captures via `StandardOutput=append:…`.  The generated `[Service]` section
/// includes a comment documenting this to avoid operator confusion.
///
/// # `MemoryMax`
///
/// Set from `cfg.memory_limit_mb` so the unit-level memory cap matches the
/// daemon's own admission-control budget.
///
/// # `opts` parameter
///
/// `InstallOptions.user` is intentionally ignored for the user unit (user units
/// always run as the invoking user).  `InstallOptions.exe_path`, if `Some`,
/// overrides the `current_exe()` path — use this in tests for deterministic
/// assertions that don't depend on the absolute path of the test binary.
pub fn generate_user_unit(cfg: &DaemonConfig, opts: &InstallOptions) -> String {
    let exe = opts.exe_path.clone().unwrap_or_else(resolve_current_exe);
    let memory_max = format_memory_max(cfg.memory_limit_mb);

    format!(
        r#"# sqryd version {version}
# Generated by `sqryd install-systemd-user`.  Do not edit manually —
# re-run the subcommand to regenerate after upgrading sqryd.
#
# Install:
#   cp this-file ~/.config/systemd/user/sqryd.service
#   systemctl --user daemon-reload
#   systemctl --user enable --now sqryd

[Unit]
Description=sqry daemon (semantic code-search graph service)
Documentation=https://github.com/verivus-oss/sqry

[Service]
Type=notify
NotifyAccess=main
ExecStart={exe} foreground
Restart=on-failure
RestartSec=3
RuntimeDirectory=sqry
RuntimeDirectoryMode=0700
# sqryd will use %t/sqry/sqryd.sock by default (XDG_RUNTIME_DIR/sqry/sqryd.sock).
Environment=SQRY_DAEMON_SOCKET=%t/sqry/sqryd.sock
Environment=RUST_BACKTRACE=1
# NOTE: Under systemd management, in-process log rotation is disabled.
# sqryd detects NOTIFY_SOCKET and skips RollingSizeAppender; all output
# is written to stdout/stderr and captured by systemd via StandardOutput
# below.  Rotation should be managed by systemd + logrotate on the
# system log path.
StandardOutput=append:%t/sqry/sqryd.log
StandardError=inherit
{memory_max}

[Install]
WantedBy=default.target
"#,
        version = SQRYD_VERSION,
        exe = exe.display(),
        memory_max = memory_max,
    )
}

/// Generate a **systemd system unit template** (`sqryd@.service`) as a `String`.
///
/// The returned text is a templated unit file using the `%i` instance
/// specifier for the user account.  Install at:
/// ```text
/// /etc/systemd/system/sqryd@.service
/// ```
/// Then enable per-user with:
/// ```text
/// systemctl enable --now sqryd@<username>
/// ```
///
/// # `%i` validation
///
/// The `%i` instance specifier must resolve to a valid POSIX user account on
/// the target system.  Use [`resolve_system_unit_user`] (called by the
/// `install-systemd-system` subcommand dispatcher in U10) to validate
/// `InstallOptions::user` before calling this function.  If the user is
/// unresolvable, the dispatcher exits with `EX_CONFIG` (78) before generation
/// runs.
///
/// The generated unit text itself contains `%i` (a verbatim systemd specifier
/// resolved by systemd at activation time) so the `String` output is
/// host-independent and can be distributed as a package artifact.
///
/// # `WantedBy=multi-user.target`
///
/// System units target `multi-user.target` (not `default.target` which is
/// user-session oriented).
pub fn generate_system_unit(cfg: &DaemonConfig, opts: &InstallOptions) -> String {
    let exe = opts.exe_path.clone().unwrap_or_else(resolve_current_exe);
    let memory_max = format_memory_max(cfg.memory_limit_mb);

    format!(
        r#"# sqryd version {version}
# Generated by `sqryd install-systemd-system`.  Do not edit manually —
# re-run the subcommand to regenerate after upgrading sqryd.
#
# Install:
#   cp this-file /etc/systemd/system/sqryd@.service
#   systemctl daemon-reload
#   systemctl enable --now sqryd@<username>
#
# The %i instance specifier is the POSIX user account to run the daemon as.
# Validate the account name with `id <username>` before enabling.

[Unit]
Description=sqry daemon (semantic code-search graph service) for %i
Documentation=https://github.com/verivus-oss/sqry
After=network.target

[Service]
Type=notify
NotifyAccess=main
User=%i
Group=%i
ExecStart={exe} foreground
Restart=on-failure
RestartSec=3
StateDirectory=sqry/%i
RuntimeDirectory=sqry/%i
RuntimeDirectoryMode=0700
LogsDirectory=sqry/%i
# Each sqryd@<user> instance binds its own socket under the per-user runtime
# directory (/run/sqry/<user>/sqryd.sock).  The %i specifier is included so
# that multiple instances cannot collide on the same socket path.
Environment=SQRY_DAEMON_SOCKET=%t/sqry/%i/sqryd.sock
Environment=RUST_BACKTRACE=1
# NOTE: Under systemd management, in-process log rotation is disabled.
# sqryd detects NOTIFY_SOCKET and skips RollingSizeAppender; all output
# is written to stdout/stderr and captured by systemd via StandardOutput
# below.  Rotation should be managed by systemd + logrotate.
# LogsDirectory= above provisions /var/log/sqry/<user>/ with correct ownership.
StandardOutput=append:/var/log/sqry/%i/sqryd.log
StandardError=inherit
{memory_max}

[Install]
WantedBy=multi-user.target
"#,
        version = SQRYD_VERSION,
        exe = exe.display(),
        memory_max = memory_max,
    )
}

/// Resolve and validate the user account for the system unit `%i` specifier.
///
/// Called by the `install-systemd-system` subcommand dispatcher (Task 10 U10)
/// **before** invoking [`generate_system_unit`].
///
/// Resolution order:
/// 1. `opts.user` if `Some` (explicit `--user <name>` CLI argument).
/// 2. `$USER` environment variable.
/// 3. Error: `EX_CONFIG` (78).
///
/// In all cases the resolved name is validated via
/// [`users::get_user_by_name`].  If the account is not found on the current
/// system, an error is returned — the caller should print the error and exit
/// with code 78 (`EX_CONFIG`).
///
/// # Errors
///
/// Returns a `String` error message (to be printed to stderr) when the user
/// account cannot be resolved or does not exist on the system.
pub fn resolve_system_unit_user(opts: &InstallOptions) -> Result<String, String> {
    let candidate = match &opts.user {
        Some(u) => u.clone(),
        None => std::env::var("USER").map_err(|_| {
            "cannot resolve system-unit user: neither --user nor $USER is set; \
                 use `sqryd install-systemd-system --user <username>`"
                .to_owned()
        })?,
    };

    // Validate the candidate against the system user database.
    if users::get_user_by_name(candidate.as_str()).is_some() {
        Ok(candidate)
    } else {
        Err(format!(
            "system-unit user {candidate:?} is not a valid POSIX account on this system; \
             use `sqryd install-systemd-system --user <username>` with a known account name"
        ))
    }
}

// ---------------------------------------------------------------------------
// Internal helpers.
// ---------------------------------------------------------------------------

/// Resolve the absolute path to the current sqryd executable.
///
/// Falls back to `"/usr/local/bin/sqryd"` if `std::env::current_exe()` fails
/// (e.g. in tests where the binary path may be non-standard) with a comment
/// noting that the path may need to be corrected.
fn resolve_current_exe() -> PathBuf {
    std::env::current_exe().unwrap_or_else(|_| PathBuf::from("/usr/local/bin/sqryd"))
}

/// Format the `MemoryMax=` directive from a memory-limit in megabytes.
///
/// Returns the full directive line, e.g. `"MemoryMax=2048M"`.
fn format_memory_max(memory_limit_mb: u64) -> String {
    format!("MemoryMax={memory_limit_mb}M")
}

// ---------------------------------------------------------------------------
// Tests.
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use std::sync::{LazyLock, Mutex};

    use super::*;

    /// Mutex serializing all tests that mutate `$USER` in the process
    /// environment.  Rust 2024 marks `std::env::set_var` / `remove_var` as
    /// `unsafe` precisely because concurrent callers can race on the shared
    /// environment block.  Any test that calls either function MUST hold this
    /// guard for its entire duration.
    static ENV_MUTEX: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));

    fn fixture_config() -> DaemonConfig {
        DaemonConfig {
            memory_limit_mb: 2_048,
            ..DaemonConfig::default()
        }
    }

    fn default_opts() -> InstallOptions {
        InstallOptions::default()
    }

    // ── User unit tests ──────────────────────────────────────────────────────

    /// The user unit must advertise `Type=notify` so that `systemctl --user
    /// start sqryd` blocks until the daemon calls `sd_notify(READY=1)`.
    #[test]
    fn systemd_user_unit_contains_type_notify() {
        let cfg = fixture_config();
        let unit = generate_user_unit(&cfg, &default_opts());
        assert!(
            unit.contains("Type=notify"),
            "user unit must contain 'Type=notify'; got:\n{unit}"
        );
        assert!(
            unit.contains("NotifyAccess=main"),
            "user unit must contain 'NotifyAccess=main'; got:\n{unit}"
        );
    }

    /// `MemoryMax=` must reflect `cfg.memory_limit_mb`.
    #[test]
    fn systemd_user_unit_contains_memory_max_matching_config() {
        let cfg = DaemonConfig {
            memory_limit_mb: 4_096,
            ..DaemonConfig::default()
        };
        let unit = generate_user_unit(&cfg, &default_opts());
        assert!(
            unit.contains("MemoryMax=4096M"),
            "user unit must contain 'MemoryMax=4096M'; got:\n{unit}"
        );
    }

    /// The [Install] section must target `default.target` for a user unit.
    #[test]
    fn systemd_user_unit_wanted_by_default_target() {
        let cfg = fixture_config();
        let unit = generate_user_unit(&cfg, &default_opts());
        assert!(
            unit.contains("WantedBy=default.target"),
            "user unit must target default.target; got:\n{unit}"
        );
    }

    /// The `ExecStart=` line must name the `foreground` subcommand.
    #[test]
    fn systemd_user_unit_exec_start_uses_foreground_subcommand() {
        let cfg = fixture_config();
        let unit = generate_user_unit(&cfg, &default_opts());
        assert!(
            unit.contains("foreground"),
            "ExecStart must include the 'foreground' subcommand; got:\n{unit}"
        );
    }

    /// RuntimeDirectory + 0700 mode must be present.
    #[test]
    fn systemd_user_unit_runtime_directory_is_0700() {
        let cfg = fixture_config();
        let unit = generate_user_unit(&cfg, &default_opts());
        assert!(
            unit.contains("RuntimeDirectory=sqry"),
            "user unit must set RuntimeDirectory; got:\n{unit}"
        );
        assert!(
            unit.contains("RuntimeDirectoryMode=0700"),
            "user unit must set RuntimeDirectoryMode=0700; got:\n{unit}"
        );
    }

    /// Version stamp must appear in the leading comment.
    #[test]
    fn systemd_user_unit_contains_version_stamp() {
        let cfg = fixture_config();
        let unit = generate_user_unit(&cfg, &default_opts());
        let expected = format!("# sqryd version {SQRYD_VERSION}");
        assert!(
            unit.contains(&expected),
            "user unit must contain version stamp '{expected}'; got:\n{unit}"
        );
    }

    /// The logging-disabled note must appear in the user unit.
    #[test]
    fn systemd_user_unit_contains_log_rotation_disabled_comment() {
        let cfg = fixture_config();
        let unit = generate_user_unit(&cfg, &default_opts());
        assert!(
            unit.contains("in-process log rotation is disabled"),
            "user unit must document that in-process log rotation is disabled; got:\n{unit}"
        );
    }

    /// The socket environment variable must be set via `%t`.
    #[test]
    fn systemd_user_unit_sets_socket_env_via_runtime_dir() {
        let cfg = fixture_config();
        let unit = generate_user_unit(&cfg, &default_opts());
        assert!(
            unit.contains("SQRY_DAEMON_SOCKET=%t/sqry/sqryd.sock"),
            "user unit must set SQRY_DAEMON_SOCKET using %t specifier; got:\n{unit}"
        );
    }

    /// Snapshot: round-trip a deterministic DaemonConfig and verify the unit
    /// contains all mandatory sections and keys.
    #[test]
    fn systemd_user_unit_snapshot_mandatory_sections() {
        let cfg = DaemonConfig {
            memory_limit_mb: 2_048,
            ..DaemonConfig::default()
        };
        let unit = generate_user_unit(&cfg, &default_opts());

        // All three ini-style sections must be present.
        assert!(unit.contains("[Unit]"), "missing [Unit]");
        assert!(unit.contains("[Service]"), "missing [Service]");
        assert!(unit.contains("[Install]"), "missing [Install]");

        // Standard service descriptors.
        assert!(unit.contains("Restart=on-failure"), "missing Restart");
        assert!(unit.contains("RestartSec=3"), "missing RestartSec");

        // Logging capture.
        assert!(
            unit.contains("StandardOutput=append:"),
            "missing StandardOutput"
        );
        assert!(
            unit.contains("StandardError=inherit"),
            "missing StandardError"
        );
    }

    // ── System unit tests ────────────────────────────────────────────────────

    /// System unit must use `%i` instance specifier for User= and Group=.
    #[test]
    fn systemd_system_unit_is_templated_with_percent_i() {
        let cfg = fixture_config();
        let unit = generate_system_unit(&cfg, &default_opts());
        assert!(
            unit.contains("User=%i"),
            "system unit must use User=%i; got:\n{unit}"
        );
        assert!(
            unit.contains("Group=%i"),
            "system unit must use Group=%i; got:\n{unit}"
        );
        assert!(
            unit.contains("StateDirectory=sqry/%i"),
            "system unit must use StateDirectory=sqry/%i; got:\n{unit}"
        );
        assert!(
            unit.contains("RuntimeDirectory=sqry/%i"),
            "system unit must use RuntimeDirectory=sqry/%i; got:\n{unit}"
        );
    }

    /// System unit must target `multi-user.target`, not `default.target`.
    #[test]
    fn systemd_system_unit_wanted_by_multi_user_target() {
        let cfg = fixture_config();
        let unit = generate_system_unit(&cfg, &default_opts());
        assert!(
            unit.contains("WantedBy=multi-user.target"),
            "system unit must target multi-user.target; got:\n{unit}"
        );
        assert!(
            !unit.contains("WantedBy=default.target"),
            "system unit must NOT target default.target; got:\n{unit}"
        );
    }

    /// System unit socket env must include `%i` for per-user isolation.
    ///
    /// Each `sqryd@<user>` instance must bind to its own socket path under
    /// `/run/sqry/<user>/`, not to a shared `/run/sqry/` path.  Also verifies
    /// that `LogsDirectory=sqry/%i` is present so systemd provisions
    /// `/var/log/sqry/<user>/` with correct ownership before exec.
    #[test]
    fn systemd_system_unit_socket_path_is_per_user_isolated() {
        let cfg = fixture_config();
        let unit = generate_system_unit(&cfg, &default_opts());
        assert!(
            unit.contains("SQRY_DAEMON_SOCKET=%t/sqry/%i/sqryd.sock"),
            "system unit socket path must include %i for per-user isolation; got:\n{unit}"
        );
        assert!(
            unit.contains("LogsDirectory=sqry/%i"),
            "system unit must declare LogsDirectory=sqry/%i to provision /var/log/sqry/<user>/; \
             got:\n{unit}"
        );
    }

    /// System unit must have `After=network.target`.
    #[test]
    fn systemd_system_unit_after_network_target() {
        let cfg = fixture_config();
        let unit = generate_system_unit(&cfg, &default_opts());
        assert!(
            unit.contains("After=network.target"),
            "system unit must declare After=network.target; got:\n{unit}"
        );
    }

    /// System unit MemoryMax must reflect cfg.memory_limit_mb.
    #[test]
    fn systemd_system_unit_memory_max_matches_config() {
        let cfg = DaemonConfig {
            memory_limit_mb: 1_024,
            ..DaemonConfig::default()
        };
        let unit = generate_system_unit(&cfg, &default_opts());
        assert!(
            unit.contains("MemoryMax=1024M"),
            "system unit must contain MemoryMax=1024M; got:\n{unit}"
        );
    }

    /// System unit snapshot: verify all mandatory sections.
    #[test]
    fn systemd_system_unit_snapshot_mandatory_sections() {
        let cfg = fixture_config();
        let unit = generate_system_unit(&cfg, &default_opts());

        assert!(unit.contains("[Unit]"), "missing [Unit]");
        assert!(unit.contains("[Service]"), "missing [Service]");
        assert!(unit.contains("[Install]"), "missing [Install]");
        assert!(unit.contains("Type=notify"), "missing Type=notify");
        assert!(unit.contains("NotifyAccess=main"), "missing NotifyAccess");
        assert!(unit.contains("ExecStart="), "missing ExecStart");
        assert!(unit.contains("Restart=on-failure"), "missing Restart");

        let expected_version = format!("# sqryd version {SQRYD_VERSION}");
        assert!(
            unit.contains(&expected_version),
            "missing version stamp: {expected_version}"
        );
    }

    // ── resolve_system_unit_user tests ───────────────────────────────────────

    /// A valid user name (the currently running user, which we know exists)
    /// must resolve successfully.
    ///
    /// We use `users::get_current_username()` to obtain a name that is
    /// guaranteed to exist, then pass it through `resolve_system_unit_user` to
    /// confirm the lookup path works end-to-end.
    #[test]
    fn systemd_system_unit_resolves_current_user_as_valid() {
        // Retrieve the current user name from the `users` crate — if that
        // returns None we skip rather than fail (unusual CI environment).
        let Some(current_user) = users::get_current_username() else {
            eprintln!("skip: could not determine current username via users crate");
            return;
        };
        let Some(username_str) = current_user.to_str() else {
            eprintln!("skip: current username is not valid UTF-8");
            return;
        };

        let opts = InstallOptions {
            user: Some(username_str.to_owned()),
            ..InstallOptions::default()
        };
        let result = resolve_system_unit_user(&opts);
        assert!(
            result.is_ok(),
            "resolve_system_unit_user must succeed for the current user {username_str:?}, \
             got: {result:?}"
        );
        assert_eq!(result.unwrap(), username_str);
    }

    /// An account name that does not exist on any system must return an error.
    #[test]
    fn systemd_system_unit_rejects_invalid_posix_user() {
        let opts = InstallOptions {
            user: Some("sqryd_nonexistent_test_account_xyzzy_12345".to_owned()),
            ..InstallOptions::default()
        };
        let result = resolve_system_unit_user(&opts);
        assert!(
            result.is_err(),
            "resolve_system_unit_user must fail for a non-existent account; got: {result:?}"
        );
        let err_msg = result.unwrap_err();
        assert!(
            err_msg.contains("not a valid POSIX account"),
            "error message must explain why validation failed; got: {err_msg}"
        );
    }

    /// When `opts.user` is `None` and `$USER` is set to the current user,
    /// resolution must succeed by falling back to `$USER`.
    ///
    /// The `ENV_MUTEX` guard serializes all env-mutation tests so that no two
    /// tests can race on the process-wide `$USER` variable (Rust 2024 marks
    /// `set_var`/`remove_var` as `unsafe` for this reason).
    #[test]
    fn systemd_system_unit_falls_back_to_user_env_var() {
        // Get the current user name to use as the fallback target.
        let Some(current_user) = users::get_current_username() else {
            eprintln!("skip: could not determine current username");
            return;
        };
        let Some(username_str) = current_user.to_str() else {
            eprintln!("skip: current username is not valid UTF-8");
            return;
        };

        // Hold the env mutex for the entire duration of the mutation window.
        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());

        // Temporarily set $USER to the current user.
        // SAFETY: ENV_MUTEX ensures no other test mutates $USER concurrently.
        let prior = std::env::var_os("USER");
        unsafe {
            std::env::set_var("USER", username_str);
        }
        let opts = InstallOptions::default();
        let result = resolve_system_unit_user(&opts);
        // Restore before asserting so a failing assert doesn't leak the env change.
        unsafe {
            match prior {
                Some(v) => std::env::set_var("USER", v),
                None => std::env::remove_var("USER"),
            }
        }

        assert!(
            result.is_ok(),
            "resolve_system_unit_user must succeed when $USER is set to a valid account; \
             got: {result:?}"
        );
    }

    /// When `opts.user` is `None` and `$USER` is unset, an error must be
    /// returned (with a message directing the user to use `--user`).
    ///
    /// The `ENV_MUTEX` guard serializes env-mutation tests; see its doc comment.
    #[test]
    fn systemd_system_unit_errors_when_no_user_and_no_env() {
        // Hold the env mutex for the entire duration of the mutation window.
        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());

        // Unset $USER for this test.
        // SAFETY: ENV_MUTEX ensures no other test mutates $USER concurrently.
        let prior = std::env::var_os("USER");
        unsafe {
            std::env::remove_var("USER");
        }
        let opts = InstallOptions::default();
        let result = resolve_system_unit_user(&opts);
        unsafe {
            match prior {
                Some(v) => std::env::set_var("USER", v),
                None => std::env::remove_var("USER"),
            }
        }

        assert!(
            result.is_err(),
            "resolve_system_unit_user must fail when neither --user nor $USER is available; \
             got: {result:?}"
        );
    }
}
