//! Lifecycle integration profile abstraction tests (#26).
//!
//! These tests pin two things:
//!
//! 1. **Parity** — the back-compat free functions
//!    (`render_applied_assets`, `merge_claude_settings`,
//!    `merge_codex_hooks`, ...) produce byte-identical output to the
//!    explicit `*_with_profile(..., &CCD_COMPAT_PROFILE)` form. This
//!    is the safety net for the refactor: any in-tree caller that
//!    used the legacy API gets exactly the same bytes after #26 as
//!    before.
//!
//! 2. **Separation** — a non-CCD profile
//!    (`LIFELOOP_DIRECT_PROFILE`) renders distinct output that does
//!    NOT contain CCD-flavored command strings, AND the CCD-compat
//!    merge logic refuses to recognize a non-CCD profile's hook
//!    entries as managed (so the two profiles do not silently
//!    interfere when an installation is migrated between profiles).

use lifeloop::host_assets::{
    self as ha, AssetStatus, CCD_COMPAT_PROFILE, CCD_RENEWAL_PROFILE, HostAdapter, IntegrationMode,
    LIFELOOP_DIRECT_PROFILE, LifecycleProfile, claude_settings_status_with_profile,
    codex_hooks_contain_managed_lifecycle_with_profile, codex_hooks_status_with_profile,
    merge_claude_settings_text_with_profile, merge_claude_settings_with_profile,
    merge_codex_hooks_with_profile, render_applied_assets_with_profile,
    render_source_assets_with_profile,
};
use serde_json::{Value, json};

// ============================================================================
// CCD-compat parity: the back-compat API equals the profile-explicit form
// ============================================================================

#[test]
fn render_applied_assets_matches_ccd_compat_profile_byte_for_byte() {
    for host in HostAdapter::ALL.iter().copied() {
        for mode in IntegrationMode::ALL.iter().copied() {
            let legacy = ha::render_applied_assets(host, mode);
            let explicit = render_applied_assets_with_profile(host, mode, &CCD_COMPAT_PROFILE);
            assert_eq!(
                legacy, explicit,
                "render_applied_assets({host:?}, {mode:?}) must match CCD_COMPAT_PROFILE"
            );
        }
    }
}

#[test]
fn render_source_assets_matches_ccd_compat_profile_byte_for_byte() {
    for host in HostAdapter::ALL.iter().copied() {
        let legacy = ha::render_source_assets(host);
        let explicit = render_source_assets_with_profile(host, &CCD_COMPAT_PROFILE);
        assert_eq!(
            legacy, explicit,
            "render_source_assets({host:?}) must match CCD_COMPAT_PROFILE"
        );
    }
}

#[test]
fn merge_claude_settings_matches_ccd_compat_profile_byte_for_byte() {
    let input = json!({
        "theme": "dark",
        "hooks": {
            "Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "echo user" }] }]
        }
    });
    let legacy = ha::merge_claude_settings(input.clone()).expect("legacy merge");
    let explicit =
        merge_claude_settings_with_profile(input, &CCD_COMPAT_PROFILE).expect("explicit merge");
    assert_eq!(legacy, explicit);
}

#[test]
fn merge_codex_hooks_matches_ccd_compat_profile_byte_for_byte() {
    let input = json!({
        "hooks": {
            "Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "echo user" }] }]
        }
    });
    let legacy = ha::merge_codex_hooks(input.clone()).expect("legacy merge");
    let explicit =
        merge_codex_hooks_with_profile(input, &CCD_COMPAT_PROFILE).expect("explicit merge");
    assert_eq!(legacy, explicit);
}

// ============================================================================
// Profile struct — invariants
// ============================================================================

#[test]
fn lifecycle_profile_id_is_stable_for_each_built_in() {
    assert_eq!(CCD_COMPAT_PROFILE.id, "ccd-compat");
    assert_eq!(CCD_RENEWAL_PROFILE.id, "ccd-renewal");
    assert_eq!(LIFELOOP_DIRECT_PROFILE.id, "lifeloop-direct");
}

#[test]
fn lifecycle_profile_command_helpers_concat_prefix_and_arg() {
    let cmd = CCD_COMPAT_PROFILE.claude_command("on-session-start");
    assert!(cmd.starts_with(CCD_COMPAT_PROFILE.claude_command_prefix));
    assert!(cmd.ends_with("on-session-start"));

    let cmd = LIFELOOP_DIRECT_PROFILE.codex_command("on-agent-end");
    assert!(cmd.starts_with(LIFELOOP_DIRECT_PROFILE.codex_command_prefix));
    assert!(cmd.ends_with("on-agent-end"));
}

#[test]
fn built_in_codex_profiles_do_not_require_git_for_workspace_path() {
    for profile in [
        CCD_COMPAT_PROFILE,
        CCD_RENEWAL_PROFILE,
        LIFELOOP_DIRECT_PROFILE,
    ] {
        let cmd = profile.codex_command("on-agent-end");
        assert!(
            !cmd.contains("git rev-parse"),
            "{id} must not hard-require Git to resolve the workspace path: {cmd}",
            id = profile.id
        );
        assert!(
            cmd.contains("LIFELOOP_WORKSPACE_DIR")
                && cmd.contains("CODEX_PROJECT_DIR")
                && cmd.contains("$PWD"),
            "{id} must render a substrate-neutral workspace fallback: {cmd}",
            id = profile.id
        );
    }
}

#[test]
fn codex_merge_scrubs_legacy_git_path_commands_for_built_in_profiles() {
    let cases = [
        (
            CCD_COMPAT_PROFILE,
            "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"$(git rev-parse --show-toplevel)\" --host codex --hook ",
        ),
        (
            LIFELOOP_DIRECT_PROFILE,
            "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"$(git rev-parse --show-toplevel)\" --host codex --hook ",
        ),
        (
            CCD_RENEWAL_PROFILE,
            "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"$(git rev-parse --show-toplevel)\" --host codex --client-cmd \"${CCD_BIN:-ccd}\" --hook ",
        ),
    ];

    for (profile, legacy_prefix) in cases {
        let old_command = format!("{legacy_prefix}on-agent-end");
        let merged = merge_codex_hooks_with_profile(
            json!({
                "hooks": {
                    "Stop": [{
                        "matcher": "*",
                        "hooks": [{ "type": "command", "command": old_command }]
                    }]
                }
            }),
            &profile,
        )
        .expect("merge");
        let stop_hooks = merged["hooks"]["Stop"][0]["hooks"].as_array().unwrap();

        assert!(
            stop_hooks
                .iter()
                .all(|hook| !hook["command"].as_str().unwrap().contains("git rev-parse")),
            "{id} merge must scrub legacy Git-based Codex hooks: {stop_hooks:?}",
            id = profile.id
        );
        assert_eq!(
            stop_hooks
                .iter()
                .filter(|hook| hook["command"].as_str()
                    == Some(profile.codex_command("on-agent-end").as_str()))
                .count(),
            1,
            "{id} merge must leave exactly one current managed hook",
            id = profile.id
        );
    }
}

#[test]
fn lifeloop_direct_profile_recognizes_ccd_compat_as_legacy() {
    // The lifeloop-direct profile is the documented successor to
    // CCD-compat (see `docs/decisions/lifecycle-profiles.md`). It
    // recognizes CCD-compat's command prefix as a legacy substring
    // so that a lifeloop-direct merge over an existing CCD-compat
    // settings.json scrubs the old CCD entries during migration —
    // see the `lifeloop_direct_merge_scrubs_ccd_compat_entries...`
    // test below for the migration behavior itself.
    let legacy = LIFELOOP_DIRECT_PROFILE.claude_legacy_substrings;
    assert!(
        legacy.contains(&CCD_COMPAT_PROFILE.claude_command_prefix),
        "lifeloop-direct must scrub CCD-compat entries during in-place migration"
    );
    assert!(
        legacy.iter().any(|s| s.contains("ccd-hook.py")),
        "lifeloop-direct must also scrub the pre-v1 Python bridge legacy form"
    );
}

#[test]
fn ccd_renewal_profile_recognizes_ccd_compat_as_legacy() {
    let legacy = CCD_RENEWAL_PROFILE.claude_legacy_substrings;
    assert!(
        legacy.contains(&CCD_COMPAT_PROFILE.claude_command_prefix),
        "ccd-renewal must scrub CCD-compat entries during in-place migration"
    );
    assert!(
        legacy.iter().any(|s| s.contains("ccd-hook.py")),
        "ccd-renewal must also scrub the pre-v1 Python bridge legacy form"
    );
}

// ============================================================================
// Separation: lifeloop-direct produces non-CCD strings
// ============================================================================

#[test]
fn lifeloop_direct_claude_command_does_not_contain_ccd_binary_reference() {
    let cmd = LIFELOOP_DIRECT_PROFILE.claude_command("on-session-start");
    assert!(
        !cmd.contains("CCD_BIN") && !cmd.contains("\"ccd\""),
        "lifeloop-direct command must not embed CCD binary references: {cmd}"
    );
    assert!(
        cmd.contains("LIFELOOP_BIN") && cmd.contains("lifeloop"),
        "lifeloop-direct command must invoke the lifeloop binary: {cmd}"
    );
}

#[test]
fn lifeloop_direct_renders_plausible_claude_settings_without_ccd_strings() {
    let assets = render_applied_assets_with_profile(
        HostAdapter::Claude,
        IntegrationMode::NativeHook,
        &LIFELOOP_DIRECT_PROFILE,
    );
    assert_eq!(assets.len(), 1);
    let settings = &assets[0];
    assert_eq!(settings.relative_path, ha::CLAUDE_TARGET_SETTINGS);
    let parsed: Value = serde_json::from_str(&settings.contents).expect("valid json");
    let session_start_cmd = parsed["hooks"]["SessionStart"][0]["hooks"][0]["command"]
        .as_str()
        .expect("session_start command");
    assert!(
        session_start_cmd.starts_with(LIFELOOP_DIRECT_PROFILE.claude_command_prefix),
        "must use lifeloop-direct prefix"
    );
    assert!(
        !session_start_cmd.contains("CCD_BIN"),
        "must not embed CCD_BIN: {session_start_cmd}"
    );
    // Every Claude managed event in the profile must appear in the
    // rendered settings.
    for (event, _, _) in LIFELOOP_DIRECT_PROFILE.claude_managed_events {
        assert!(
            parsed["hooks"].get(event).is_some(),
            "rendered settings missing managed event {event}"
        );
    }
}

#[test]
fn lifeloop_direct_renders_plausible_codex_hooks_without_ccd_strings() {
    let assets = render_applied_assets_with_profile(
        HostAdapter::Codex,
        IntegrationMode::NativeHook,
        &LIFELOOP_DIRECT_PROFILE,
    );
    let hooks_asset = assets
        .iter()
        .find(|a| a.relative_path == ha::CODEX_TARGET_HOOKS)
        .expect("hooks.json present");
    let parsed: Value = serde_json::from_str(&hooks_asset.contents).expect("valid json");
    assert!(
        codex_hooks_contain_managed_lifecycle_with_profile(&parsed, &LIFELOOP_DIRECT_PROFILE),
        "lifeloop-direct hooks must satisfy its own managed-lifecycle predicate"
    );
    // CCD-flavored predicate must NOT match — they are different shapes.
    assert!(
        !codex_hooks_contain_managed_lifecycle_with_profile(&parsed, &CCD_COMPAT_PROFILE),
        "CCD predicate must reject lifeloop-direct rendered hooks"
    );
}

#[test]
fn lifeloop_direct_codex_status_text_is_lifeloop_flavored() {
    let assets = render_applied_assets_with_profile(
        HostAdapter::Codex,
        IntegrationMode::NativeHook,
        &LIFELOOP_DIRECT_PROFILE,
    );
    let hooks_asset = assets
        .iter()
        .find(|a| a.relative_path == ha::CODEX_TARGET_HOOKS)
        .expect("hooks.json present");
    let parsed: Value = serde_json::from_str(&hooks_asset.contents).expect("valid json");
    let session_start = &parsed["hooks"]["SessionStart"][0]["hooks"][0];
    assert_eq!(
        session_start["statusMessage"],
        Value::String("Loading Lifeloop session context".to_owned()),
        "status text must reflect the binary the harness is actually invoking"
    );
}

#[test]
fn ccd_renewal_profile_invokes_lifeloop_broker_with_ccd_client() {
    let assets = render_applied_assets_with_profile(
        HostAdapter::Codex,
        IntegrationMode::NativeHook,
        &CCD_RENEWAL_PROFILE,
    );
    let hooks_asset = assets
        .iter()
        .find(|a| a.relative_path == ha::CODEX_TARGET_HOOKS)
        .expect("hooks.json present");
    let parsed: Value = serde_json::from_str(&hooks_asset.contents).expect("valid json");
    let session_start = parsed["hooks"]["SessionStart"][0]["hooks"][0]["command"]
        .as_str()
        .expect("session start command");
    assert!(
        session_start.starts_with(CCD_RENEWAL_PROFILE.codex_command_prefix),
        "must use ccd-renewal prefix"
    );
    assert!(
        session_start.contains("lifeloop")
            && session_start.contains("host-hook")
            && session_start.contains("--client-cmd")
            && session_start.contains("CCD_BIN"),
        "ccd-renewal profile must broker CCD through Lifeloop: {session_start}"
    );
    assert!(
        !session_start.contains("ccd\" --output hook-protocol host-hook"),
        "ccd-renewal must not render the removed CCD host-hook command: {session_start}"
    );
}

// ============================================================================
// Cross-profile non-interference
// ============================================================================

#[test]
fn ccd_compat_merge_does_not_recognize_lifeloop_direct_managed_entries() {
    // Render a settings.json under lifeloop-direct, then run the
    // CCD-compat merge over it. The CCD merge must NOT treat the
    // lifeloop-direct entries as its own managed entries (i.e. it
    // must not scrub them); it must additively append its own
    // CCD-compat entries alongside.
    let assets = render_applied_assets_with_profile(
        HostAdapter::Claude,
        IntegrationMode::NativeHook,
        &LIFELOOP_DIRECT_PROFILE,
    );
    let lifeloop_direct_settings = &assets[0].contents;
    let merged = merge_claude_settings_text_with_profile(
        Some(lifeloop_direct_settings),
        false,
        &CCD_COMPAT_PROFILE,
    )
    .expect("merge should succeed")
    .expect("rendered");

    let parsed: Value = serde_json::from_str(&merged.rendered).expect("valid json");
    let session_start_entries = parsed["hooks"]["SessionStart"][0]["hooks"]
        .as_array()
        .expect("array");
    // Both profiles' SessionStart entries must coexist after the
    // CCD-compat merge.
    let lifeloop_direct_present = session_start_entries.iter().any(|e| {
        e.get("command")
            .and_then(Value::as_str)
            .map(|s| s.starts_with(LIFELOOP_DIRECT_PROFILE.claude_command_prefix))
            .unwrap_or(false)
    });
    let ccd_compat_present = session_start_entries.iter().any(|e| {
        e.get("command")
            .and_then(Value::as_str)
            .map(|s| s.starts_with(CCD_COMPAT_PROFILE.claude_command_prefix))
            .unwrap_or(false)
    });
    assert!(
        lifeloop_direct_present,
        "CCD merge must preserve lifeloop-direct entry: {session_start_entries:?}"
    );
    assert!(
        ccd_compat_present,
        "CCD merge must add its own entry: {session_start_entries:?}"
    );
}

#[test]
fn lifeloop_direct_merge_scrubs_ccd_compat_entries_for_clean_migration() {
    // The slimdown narrative (docs/decisions/lifecycle-profiles.md,
    // dusk-network/ccd#723) says: switching the active install
    // profile from CCD_COMPAT to LIFELOOP_DIRECT yields a single
    // set of managed hooks in the new shape — not two coexisting
    // sets. Render a CCD-compat settings.json, merge it with the
    // lifeloop-direct profile, and assert the result has only the
    // lifeloop-direct managed entry per event (plus any user-owned
    // entries the merge preserves).
    let ccd_assets = render_applied_assets_with_profile(
        HostAdapter::Claude,
        IntegrationMode::NativeHook,
        &CCD_COMPAT_PROFILE,
    );
    let ccd_compat_settings = &ccd_assets[0].contents;

    let merged = merge_claude_settings_text_with_profile(
        Some(ccd_compat_settings),
        false,
        &LIFELOOP_DIRECT_PROFILE,
    )
    .expect("merge ok")
    .expect("rendered");

    let parsed: Value = serde_json::from_str(&merged.rendered).expect("valid json");
    let session_start_entries = parsed["hooks"]["SessionStart"][0]["hooks"]
        .as_array()
        .expect("array");
    // Exactly one managed hook entry: the lifeloop-direct one.
    assert_eq!(
        session_start_entries.len(),
        1,
        "migration must collapse to a single managed entry, found: {session_start_entries:?}"
    );
    let cmd = session_start_entries[0]["command"]
        .as_str()
        .expect("command string");
    assert!(
        cmd.starts_with(LIFELOOP_DIRECT_PROFILE.claude_command_prefix),
        "post-migration entry must use lifeloop-direct prefix, got: {cmd}"
    );
    assert!(
        !cmd.contains("CCD_BIN"),
        "post-migration entry must not retain CCD binary references, got: {cmd}"
    );
}

#[test]
fn ccd_renewal_merge_scrubs_ccd_compat_entries_for_clean_migration() {
    let ccd_assets = render_applied_assets_with_profile(
        HostAdapter::Claude,
        IntegrationMode::NativeHook,
        &CCD_COMPAT_PROFILE,
    );
    let ccd_compat_settings = &ccd_assets[0].contents;

    let merged = merge_claude_settings_text_with_profile(
        Some(ccd_compat_settings),
        false,
        &CCD_RENEWAL_PROFILE,
    )
    .expect("merge ok")
    .expect("rendered");

    let parsed: Value = serde_json::from_str(&merged.rendered).expect("valid json");
    let entries = parsed["hooks"]["SessionStart"][0]["hooks"]
        .as_array()
        .expect("array");
    assert_eq!(
        entries.len(),
        1,
        "migration must collapse to a single managed entry, found: {entries:?}"
    );
    let cmd = entries[0]["command"].as_str().expect("command string");
    assert!(
        cmd.starts_with(CCD_RENEWAL_PROFILE.claude_command_prefix),
        "post-migration entry must use ccd-renewal prefix, got: {cmd}"
    );
    assert!(
        !cmd.starts_with(CCD_COMPAT_PROFILE.claude_command_prefix),
        "post-migration entry must not retain CCD-compat prefix, got: {cmd}"
    );
}

#[test]
fn lifeloop_direct_merge_preserves_user_owned_entries_during_migration() {
    // Migration scrub must NOT touch entries that are not part of
    // CCD-compat's managed shape — user-owned hooks survive a
    // profile switch.
    let input = json!({
        "hooks": {
            "SessionStart": [{
                "matcher": "startup|resume|clear|compact",
                "hooks": [
                    { "type": "command", "command": "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"$CLAUDE_PROJECT_DIR\" --host claude --hook on-session-start" },
                    { "type": "command", "command": "echo user-owned" }
                ]
            }]
        }
    });

    let merged =
        merge_claude_settings_with_profile(input, &LIFELOOP_DIRECT_PROFILE).expect("merge ok");
    let entries = merged["hooks"]["SessionStart"][0]["hooks"]
        .as_array()
        .expect("array");
    assert_eq!(
        entries.len(),
        2,
        "expected user entry + lifeloop-direct entry, got: {entries:?}"
    );
    let user_present = entries.iter().any(|e| {
        e.get("command")
            .and_then(Value::as_str)
            .map(|s| s == "echo user-owned")
            .unwrap_or(false)
    });
    assert!(
        user_present,
        "user-owned entry must be preserved through migration"
    );
    let lifeloop_direct_present = entries.iter().any(|e| {
        e.get("command")
            .and_then(Value::as_str)
            .map(|s| s.starts_with(LIFELOOP_DIRECT_PROFILE.claude_command_prefix))
            .unwrap_or(false)
    });
    assert!(
        lifeloop_direct_present,
        "lifeloop-direct managed entry must be added"
    );
}

#[test]
fn lifeloop_direct_merge_replaces_its_own_stale_entry_idempotently() {
    let assets = render_applied_assets_with_profile(
        HostAdapter::Claude,
        IntegrationMode::NativeHook,
        &LIFELOOP_DIRECT_PROFILE,
    );
    let body = &assets[0].contents;
    let once = merge_claude_settings_text_with_profile(Some(body), false, &LIFELOOP_DIRECT_PROFILE)
        .expect("ok")
        .expect("rendered");
    assert_eq!(
        once.rendered, *body,
        "rendered settings.json must be a fixed point of its own profile's merge"
    );
}

// ============================================================================
// Status reporting honors the profile
// ============================================================================

#[test]
fn claude_settings_status_with_profile_separates_profiles() {
    let assets = render_applied_assets_with_profile(
        HostAdapter::Claude,
        IntegrationMode::NativeHook,
        &LIFELOOP_DIRECT_PROFILE,
    );
    let body = &assets[0].contents;
    assert_eq!(
        claude_settings_status_with_profile(Some(body), &LIFELOOP_DIRECT_PROFILE),
        AssetStatus::Present,
        "lifeloop-direct's own rendered output must be Present under that profile"
    );
    // CCD-compat would re-render the file by ADDING its own managed
    // entries (per the cross-profile non-interference test), so the
    // CCD-compat status reads as Drifted — exactly what we want when
    // the operator is migrating from one profile to the other.
    assert_eq!(
        claude_settings_status_with_profile(Some(body), &CCD_COMPAT_PROFILE),
        AssetStatus::Drifted,
        "lifeloop-direct rendered output must NOT be Present under CCD_COMPAT_PROFILE"
    );
}

#[test]
fn codex_hooks_status_with_profile_separates_profiles() {
    let assets = render_applied_assets_with_profile(
        HostAdapter::Codex,
        IntegrationMode::NativeHook,
        &LIFELOOP_DIRECT_PROFILE,
    );
    let hooks = assets
        .iter()
        .find(|a| a.relative_path == ha::CODEX_TARGET_HOOKS)
        .expect("hooks.json present");
    assert_eq!(
        codex_hooks_status_with_profile(Some(&hooks.contents), &LIFELOOP_DIRECT_PROFILE),
        AssetStatus::Present
    );
    assert_eq!(
        codex_hooks_status_with_profile(Some(&hooks.contents), &CCD_COMPAT_PROFILE),
        AssetStatus::Drifted
    );
}

// ============================================================================
// Custom profiles
// ============================================================================

#[test]
fn custom_profile_renders_with_its_own_command_prefix() {
    // A consumer-defined profile (not built in) must round-trip
    // through the same renderers without editing core code.
    static CUSTOM_CLAUDE_EVENTS: &[(&str, &str, &str)] = &[
        ("SessionStart", "on-session-start", "*"),
        ("Stop", "on-agent-end", "*"),
    ];
    static CUSTOM_CODEX_EVENTS: &[(&str, &str, &str, &str)] = &[(
        "SessionStart",
        "on-session-start",
        "*",
        "Custom client status",
    )];
    let custom = LifecycleProfile {
        id: "custom-test",
        claude_command_prefix: "custom-broker --hook ",
        claude_legacy_substrings: &[],
        claude_managed_events: CUSTOM_CLAUDE_EVENTS,
        codex_command_prefix: "custom-broker --codex --hook ",
        codex_managed_events: CUSTOM_CODEX_EVENTS,
    };

    let assets = render_applied_assets_with_profile(
        HostAdapter::Claude,
        IntegrationMode::NativeHook,
        &custom,
    );
    let parsed: Value = serde_json::from_str(&assets[0].contents).expect("valid json");
    let cmd = parsed["hooks"]["SessionStart"][0]["hooks"][0]["command"]
        .as_str()
        .expect("command");
    assert_eq!(cmd, "custom-broker --hook on-session-start");
    // The custom profile only declares two events — the CCD-compat
    // managed event count (5) must NOT leak through.
    assert_eq!(
        parsed["hooks"].as_object().expect("object").len(),
        2,
        "custom profile must drive the event set, not leak CCD-compat events"
    );
}

#[test]
fn invalid_profile_prefixes_fail_closed() {
    static CUSTOM_CLAUDE_EVENTS: &[(&str, &str, &str)] =
        &[("SessionStart", "on-session-start", "*")];
    static CUSTOM_CODEX_EVENTS: &[(&str, &str, &str, &str)] =
        &[("SessionStart", "on-session-start", "*", "status")];
    let invalid = LifecycleProfile {
        id: "invalid-empty-prefix",
        claude_command_prefix: "",
        claude_legacy_substrings: &[],
        claude_managed_events: CUSTOM_CLAUDE_EVENTS,
        codex_command_prefix: "",
        codex_managed_events: CUSTOM_CODEX_EVENTS,
    };

    let existing = serde_json::json!({
        "hooks": {
            "SessionStart": [{
                "matcher": "*",
                "hooks": [{ "type": "command", "command": "user-owned" }]
            }]
        }
    });
    assert!(invalid.validate().is_err());
    assert!(merge_claude_settings_with_profile(existing.clone(), &invalid).is_none());
    assert!(merge_codex_hooks_with_profile(existing, &invalid).is_none());
    assert!(
        render_applied_assets_with_profile(
            HostAdapter::Claude,
            IntegrationMode::NativeHook,
            &invalid
        )
        .is_empty()
    );
}
