//! Host integration asset rendering and merge tests.
//!
//! These tests pin the merge-safety behavior the spec calls out at
//! `docs/specs/lifecycle-contract/body.md` line 257 ("install/apply remains
//! a CLI compatibility command, but the installed assets are
//! lifecycle integration assets whose semantic target is Lifeloop
//! normalization"). The merge tests are direct ports of the proofs
//! that lived in CCD's `src/commands/host/{settings_merge,
//! codex_hooks_merge}.rs` before issue #4 extracted them into
//! Lifeloop. The tests are repeated here so the safety behavior is
//! preserved by Lifeloop's own test suite (issue #4 acceptance).

use lifeloop::host_assets::{
    self as ha, AssetStatus, FileAction, HostAdapter, IntegrationMode, MergedFile, RenderedAsset,
    aggregate_status, asset_status, byte_equal_asset_status, ccd_compat_claude_command,
    codex_hooks_contain_managed_lifecycle, combine_actions, merge_claude_settings,
    merge_claude_settings_text, merge_codex_config_text, merge_codex_hooks, merge_codex_hooks_text,
    render_applied_assets, render_required_source_assets, render_source_assets, supported_modes,
    supports_mode,
};
use serde_json::{Value, json};

// ============================================================================
// HostAdapter / IntegrationMode
// ============================================================================

#[test]
fn host_adapter_round_trips_canonical_ids() {
    for host in HostAdapter::ALL {
        assert_eq!(HostAdapter::from_id(host.as_str()), Some(*host));
    }
    assert_eq!(
        HostAdapter::from_id("claude-code"),
        Some(HostAdapter::Claude)
    );
    assert_eq!(HostAdapter::from_id("unknown"), None);
}

#[test]
fn integration_mode_round_trips_wire_strings() {
    for mode in IntegrationMode::ALL {
        assert_eq!(IntegrationMode::from_id(mode.as_str()), Some(*mode));
    }
    assert_eq!(IntegrationMode::from_id("telemetry_only"), None);
}

#[test]
fn integration_mode_converts_from_lifecycle_mode() {
    use lifeloop::IntegrationMode as Lifecycle;
    assert_eq!(
        IntegrationMode::from_lifecycle_mode(Lifecycle::NativeHook),
        Some(IntegrationMode::NativeHook)
    );
    assert_eq!(
        IntegrationMode::from_lifecycle_mode(Lifecycle::TelemetryOnly),
        None,
        "telemetry_only has no asset surface"
    );
}

#[test]
fn supports_mode_pins_per_host_matrix() {
    assert!(supports_mode(
        HostAdapter::Claude,
        IntegrationMode::NativeHook
    ));
    assert!(!supports_mode(
        HostAdapter::Claude,
        IntegrationMode::ManualSkill
    ));

    assert!(supports_mode(
        HostAdapter::Codex,
        IntegrationMode::ManualSkill
    ));
    assert!(supports_mode(
        HostAdapter::Codex,
        IntegrationMode::LauncherWrapper
    ));
    assert!(supports_mode(
        HostAdapter::Codex,
        IntegrationMode::NativeHook
    ));
    assert!(!supports_mode(
        HostAdapter::Codex,
        IntegrationMode::ReferenceAdapter
    ));

    assert!(supports_mode(
        HostAdapter::Hermes,
        IntegrationMode::ReferenceAdapter
    ));
    assert!(supports_mode(
        HostAdapter::OpenClaw,
        IntegrationMode::ReferenceAdapter
    ));
    assert!(!supports_mode(
        HostAdapter::Hermes,
        IntegrationMode::NativeHook
    ));
}

#[test]
fn supported_modes_lists_match_supports_mode() {
    assert_eq!(
        supported_modes(HostAdapter::Claude),
        &[IntegrationMode::NativeHook]
    );
    assert_eq!(
        supported_modes(HostAdapter::Codex),
        &[
            IntegrationMode::ManualSkill,
            IntegrationMode::LauncherWrapper,
            IntegrationMode::NativeHook,
        ]
    );
    assert_eq!(
        supported_modes(HostAdapter::Hermes),
        &[IntegrationMode::ReferenceAdapter]
    );
    assert_eq!(
        supported_modes(HostAdapter::OpenClaw),
        &[IntegrationMode::ReferenceAdapter]
    );

    for host in HostAdapter::ALL {
        for mode in supported_modes(*host) {
            assert!(
                supports_mode(*host, *mode),
                "{} should support {}",
                host.as_str(),
                mode.as_str()
            );
        }
    }
}

// ============================================================================
// Asset rendering
// ============================================================================

#[test]
fn render_source_assets_for_each_host_pins_path_set() {
    let claude = render_source_assets(HostAdapter::Claude);
    assert_eq!(
        claude.iter().map(|a| a.relative_path).collect::<Vec<_>>(),
        vec![ha::CLAUDE_SOURCE_SETTINGS]
    );

    let codex = render_source_assets(HostAdapter::Codex);
    let codex_paths: Vec<_> = codex.iter().map(|a| a.relative_path).collect();
    assert_eq!(
        codex_paths,
        vec![
            ha::CODEX_SOURCE_README,
            ha::CODEX_SOURCE_CONFIG,
            ha::CODEX_SOURCE_HOOKS,
            ha::CODEX_SOURCE_LAUNCHER,
        ]
    );
    let launcher = codex
        .iter()
        .find(|a| a.relative_path == ha::CODEX_SOURCE_LAUNCHER)
        .expect("launcher present");
    assert_eq!(launcher.mode, Some(0o755), "launcher must be executable");

    assert_eq!(
        render_source_assets(HostAdapter::Hermes)
            .iter()
            .map(|a| a.relative_path)
            .collect::<Vec<_>>(),
        vec![ha::HERMES_SOURCE_ADAPTER]
    );
    assert_eq!(
        render_source_assets(HostAdapter::OpenClaw)
            .iter()
            .map(|a| a.relative_path)
            .collect::<Vec<_>>(),
        vec![ha::OPENCLAW_SOURCE_ADAPTER]
    );
}

#[test]
fn render_required_source_assets_filters_codex_modes() {
    let manual = render_required_source_assets(HostAdapter::Codex, IntegrationMode::ManualSkill);
    assert_eq!(
        manual.iter().map(|a| a.relative_path).collect::<Vec<_>>(),
        vec![ha::CODEX_SOURCE_README]
    );

    let launcher =
        render_required_source_assets(HostAdapter::Codex, IntegrationMode::LauncherWrapper);
    let paths: Vec<_> = launcher.iter().map(|a| a.relative_path).collect();
    assert_eq!(
        paths,
        vec![ha::CODEX_SOURCE_README, ha::CODEX_SOURCE_LAUNCHER]
    );

    let native = render_required_source_assets(HostAdapter::Codex, IntegrationMode::NativeHook);
    let native_paths: Vec<_> = native.iter().map(|a| a.relative_path).collect();
    assert_eq!(
        native_paths,
        vec![
            ha::CODEX_SOURCE_README,
            ha::CODEX_SOURCE_CONFIG,
            ha::CODEX_SOURCE_HOOKS,
        ]
    );
}

#[test]
fn render_applied_assets_per_host_mode_matches_install_matrix() {
    let claude = render_applied_assets(HostAdapter::Claude, IntegrationMode::NativeHook);
    assert_eq!(
        claude.iter().map(|a| a.relative_path).collect::<Vec<_>>(),
        vec![ha::CLAUDE_TARGET_SETTINGS]
    );

    let codex_native = render_applied_assets(HostAdapter::Codex, IntegrationMode::NativeHook);
    let native_paths: Vec<_> = codex_native.iter().map(|a| a.relative_path).collect();
    assert_eq!(
        native_paths,
        vec![ha::CODEX_TARGET_CONFIG, ha::CODEX_TARGET_HOOKS]
    );

    let codex_launcher =
        render_applied_assets(HostAdapter::Codex, IntegrationMode::LauncherWrapper);
    let launcher_assets: Vec<_> = codex_launcher.iter().collect();
    assert_eq!(launcher_assets.len(), 1);
    assert_eq!(launcher_assets[0].relative_path, ha::CODEX_TARGET_LAUNCHER);
    assert_eq!(launcher_assets[0].mode, Some(0o755));

    // ManualSkill installs no files (scaffold-only mode).
    let codex_manual = render_applied_assets(HostAdapter::Codex, IntegrationMode::ManualSkill);
    assert!(codex_manual.is_empty());

    assert_eq!(
        render_applied_assets(HostAdapter::Hermes, IntegrationMode::ReferenceAdapter)
            .iter()
            .map(|a| a.relative_path)
            .collect::<Vec<_>>(),
        vec![ha::HERMES_TARGET_ADAPTER]
    );
    assert_eq!(
        render_applied_assets(HostAdapter::OpenClaw, IntegrationMode::ReferenceAdapter)
            .iter()
            .map(|a| a.relative_path)
            .collect::<Vec<_>>(),
        vec![ha::OPENCLAW_TARGET_ADAPTER]
    );
}

#[test]
fn rendered_claude_settings_carries_managed_command_prefix() {
    // Compare via the *parsed* value, not the serialized text: JSON escapes
    // the quote characters inside the command prefix, so a substring match
    // against the unescaped constant would fail spuriously.
    let claude = render_applied_assets(HostAdapter::Claude, IntegrationMode::NativeHook);
    let settings = &claude[0];
    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(ha::CCD_COMPAT_CLAUDE_COMMAND_PREFIX),
        "command must start with CCD-compat Claude prefix: {session_start_cmd}"
    );
    assert_eq!(
        session_start_cmd,
        ccd_compat_claude_command("on-session-start")
    );
}

#[test]
fn rendered_codex_hooks_contain_full_managed_lifecycle() {
    let codex = render_applied_assets(HostAdapter::Codex, IntegrationMode::NativeHook);
    let hooks_asset = codex
        .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(&parsed),
        "rendered Codex hooks must satisfy codex_hooks_contain_managed_lifecycle"
    );
}

// ============================================================================
// Claude settings merge — ports of CCD settings_merge tests
// ============================================================================

#[test]
fn merge_claude_into_empty_object_produces_all_managed_events() {
    let merged = merge_claude_settings(json!({})).expect("merge should succeed on empty");
    let hooks = &merged["hooks"];
    for event in [
        "SessionStart",
        "UserPromptSubmit",
        "PreCompact",
        "Stop",
        "SessionEnd",
    ] {
        assert!(hooks.get(event).is_some(), "missing event {event}");
    }
    assert!(
        hooks.get("TaskCompleted").is_none(),
        "TaskCompleted must not be installed"
    );
}

#[test]
fn merge_claude_preserves_unrelated_top_level_keys() {
    let input = json!({
        "theme": "dark",
        "hooks": {
            "Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "echo user" }] }]
        }
    });
    let merged = merge_claude_settings(input).expect("merge should succeed");
    assert_eq!(merged["theme"], Value::String("dark".to_owned()));
    let stop_hooks = merged["hooks"]["Stop"][0]["hooks"].as_array().unwrap();
    assert_eq!(stop_hooks.len(), 2);
    assert_eq!(
        stop_hooks[0]["command"],
        Value::String("echo user".to_owned())
    );
    assert!(
        stop_hooks[1]["command"]
            .as_str()
            .unwrap()
            .starts_with(ha::CCD_COMPAT_CLAUDE_COMMAND_PREFIX)
    );
}

#[test]
fn merge_claude_is_idempotent_across_two_merges() {
    let input = json!({ "theme": "dark" });
    let once = merge_claude_settings(input).expect("first merge");
    let twice = merge_claude_settings(once.clone()).expect("second merge");
    assert_eq!(
        serde_json::to_string_pretty(&once).unwrap(),
        serde_json::to_string_pretty(&twice).unwrap(),
    );
}

#[test]
fn merge_claude_replaces_pre_v1_python_entries() {
    let input = json!({
        "hooks": {
            "Stop": [{
                "matcher": "*",
                "hooks": [{
                    "type": "command",
                    "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ccd-hook.py on-agent-end"
                }]
            }]
        }
    });
    let merged = merge_claude_settings(input).expect("merge should succeed");
    let stop_hooks = merged["hooks"]["Stop"][0]["hooks"].as_array().unwrap();
    assert_eq!(stop_hooks.len(), 1);
    assert!(
        stop_hooks[0]["command"]
            .as_str()
            .unwrap()
            .starts_with(ha::CCD_COMPAT_CLAUDE_COMMAND_PREFIX)
    );
}

#[test]
fn merge_claude_removes_retired_task_completed_python_bridge_entries() {
    let input = json!({
        "hooks": {
            "TaskCompleted": [{
                "matcher": "*",
                "hooks": [{
                    "type": "command",
                    "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ccd-hook.py on-agent-end"
                }]
            }],
            "Stop": [{
                "matcher": "*",
                "hooks": []
            }]
        }
    });
    let merged = merge_claude_settings(input).expect("merge should succeed");
    assert!(
        merged["hooks"].get("TaskCompleted").is_none(),
        "retired CCD TaskCompleted entry should be removed: {merged}"
    );
}

#[test]
fn merge_claude_preserves_user_owned_task_completed_entries() {
    let input = json!({
        "hooks": {
            "TaskCompleted": [{
                "matcher": "*",
                "hooks": [{
                    "type": "command",
                    "command": "echo user-task-completed"
                }]
            }]
        }
    });
    let merged = merge_claude_settings(input).expect("merge should succeed");
    let hooks = merged["hooks"]["TaskCompleted"][0]["hooks"]
        .as_array()
        .unwrap();
    assert_eq!(hooks[0]["command"], "echo user-task-completed");
}

#[test]
fn merge_claude_returns_none_when_hooks_is_not_an_object() {
    let input = json!({ "hooks": [1, 2, 3] });
    assert!(merge_claude_settings(input).is_none());
}

#[test]
fn merge_claude_returns_none_when_managed_event_is_not_an_array() {
    let input = json!({ "hooks": { "Stop": { "unexpected": "shape" } } });
    assert!(merge_claude_settings(input).is_none());
}

#[test]
fn merge_claude_text_force_rebuilds_when_input_is_malformed_top_level() {
    let merged = merge_claude_settings_text(Some("[1, 2, 3]"), true)
        .expect("ok")
        .expect("force rebuild yields merged");
    let parsed: Value = serde_json::from_str(&merged.rendered).expect("parseable");
    assert!(
        parsed["hooks"]["Stop"].is_array(),
        "rebuild should populate managed events"
    );
}

#[test]
fn merge_claude_text_force_rebuilds_when_json_is_invalid() {
    let merged = merge_claude_settings_text(Some("{ not json"), true)
        .expect("ok")
        .expect("force rebuild yields merged");
    let parsed: Value = serde_json::from_str(&merged.rendered).expect("parseable");
    assert!(parsed["hooks"]["SessionStart"].is_array());
    assert!(
        merge_claude_settings_text(Some("{ not json"), false)
            .expect("ok")
            .is_none()
    );
}

#[test]
fn merge_claude_text_force_rebuilds_when_managed_event_shape_is_bad() {
    let malformed = r#"{"hooks":{"Stop":{"unexpected":"shape"}}}"#;
    let merged = merge_claude_settings_text(Some(malformed), true)
        .expect("ok")
        .expect("force rebuild yields merged");
    let parsed: Value = serde_json::from_str(&merged.rendered).expect("parseable");
    assert!(parsed["hooks"]["Stop"].is_array());
    assert!(
        merge_claude_settings_text(Some(malformed), false)
            .expect("ok")
            .is_none()
    );
}

#[test]
fn merge_claude_text_no_force_returns_none_when_file_is_malformed() {
    assert!(
        merge_claude_settings_text(Some("[1, 2, 3]"), false)
            .expect("ok")
            .is_none()
    );
}

#[test]
fn merge_claude_text_creates_from_scratch_when_existing_is_none() {
    let merged = merge_claude_settings_text(None, false)
        .expect("ok")
        .expect("creates new file");
    assert!(merged.existing.is_none());
    let parsed: Value = serde_json::from_str(&merged.rendered).expect("valid json");
    assert!(parsed["hooks"]["SessionStart"].is_array());
}

// ============================================================================
// Codex hooks merge — ports of CCD codex_hooks_merge tests
// ============================================================================

#[test]
fn merge_codex_into_empty_object_produces_codex_lifecycle_hooks() {
    let merged = merge_codex_hooks(json!({})).expect("merge");
    assert!(codex_hooks_contain_managed_lifecycle(&merged));
}

#[test]
fn merge_codex_preserves_user_hooks_in_same_event() {
    let input = json!({
        "hooks": {
            "Stop": [{
                "matcher": "*",
                "hooks": [{ "type": "command", "command": "echo user-owned-stop-hook" }]
            }]
        }
    });
    let merged = merge_codex_hooks(input).expect("merge");
    let stop_hooks = merged["hooks"]["Stop"][0]["hooks"].as_array().unwrap();
    assert_eq!(stop_hooks[0]["command"], "echo user-owned-stop-hook");
    assert!(stop_hooks.iter().any(|hook| {
        hook["command"]
            .as_str()
            .unwrap()
            .starts_with(ha::CCD_COMPAT_CODEX_COMMAND_PREFIX)
    }));
}

#[test]
fn merge_codex_is_idempotent() {
    let once = merge_codex_hooks(json!({})).expect("first merge");
    let twice = merge_codex_hooks(once.clone()).expect("second merge");
    assert_eq!(once, twice);
}

#[test]
fn merge_codex_rejects_malformed_event_shape() {
    let input = json!({ "hooks": { "Stop": { "unexpected": "shape" } } });
    assert!(merge_codex_hooks(input).is_none());
}

#[test]
fn merge_codex_text_returns_none_for_invalid_json() {
    assert!(
        merge_codex_hooks_text(Some("not json"), false)
            .expect("ok")
            .is_none()
    );
}

#[test]
fn merge_codex_text_creates_from_scratch_when_none() {
    let merged = merge_codex_hooks_text(None, false)
        .expect("ok")
        .expect("creates new file");
    let parsed: Value = serde_json::from_str(&merged.rendered).unwrap();
    assert!(codex_hooks_contain_managed_lifecycle(&parsed));
}

// ============================================================================
// Codex config merge
// ============================================================================

#[test]
fn merge_codex_config_creates_features_table_when_absent() {
    let merged = merge_codex_config_text(None).expect("ok");
    assert!(
        merged.rendered.contains("[features]"),
        "rendered TOML should declare [features] table: {}",
        merged.rendered
    );
    assert!(
        merged.rendered.contains("hooks = true"),
        "rendered TOML should set hooks=true: {}",
        merged.rendered
    );
}

#[test]
fn merge_codex_config_treats_whitespace_body_as_empty() {
    let merged = merge_codex_config_text(Some(" \n\t \n")).expect("ok");
    let parsed: toml::Value = merged.rendered.parse().expect("parseable");
    assert_eq!(parsed["features"]["hooks"].as_bool(), Some(true));
}

#[test]
fn merge_codex_config_preserves_unrelated_keys() {
    let existing = "[other]\nvalue = 42\n";
    let merged = merge_codex_config_text(Some(existing)).expect("ok");
    let parsed: toml::Value = merged.rendered.parse().expect("parseable");
    assert_eq!(parsed["other"]["value"].as_integer(), Some(42));
    assert_eq!(parsed["features"]["hooks"].as_bool(), Some(true));
}

#[test]
fn merge_codex_config_idempotent() {
    let once = merge_codex_config_text(None).expect("once");
    let twice = merge_codex_config_text(Some(&once.rendered)).expect("twice");
    assert_eq!(once.rendered, twice.rendered);
}

#[test]
fn merge_codex_config_rejects_non_table_top_level() {
    let err = merge_codex_config_text(Some("invalid = ["))
        .expect_err("parse error expected for malformed TOML");
    let _ = err.to_string();
}

// ============================================================================
// Status reporting
// ============================================================================

#[test]
fn byte_equal_status_present_when_content_matches() {
    let asset = RenderedAsset {
        relative_path: ".something/template",
        contents: "hello\n".into(),
        mode: None,
    };
    assert_eq!(
        byte_equal_asset_status(&asset, Some("hello\n"), None),
        AssetStatus::Present
    );
}

#[test]
fn byte_equal_status_drifted_on_content_difference() {
    let asset = RenderedAsset {
        relative_path: ".something/template",
        contents: "hello\n".into(),
        mode: None,
    };
    assert_eq!(
        byte_equal_asset_status(&asset, Some("goodbye\n"), None),
        AssetStatus::Drifted
    );
}

#[test]
fn byte_equal_status_missing_when_file_absent() {
    let asset = RenderedAsset {
        relative_path: ".something/template",
        contents: "hello\n".into(),
        mode: None,
    };
    assert_eq!(
        byte_equal_asset_status(&asset, None, None),
        AssetStatus::Missing
    );
}

#[test]
fn byte_equal_status_drift_on_mode_mismatch() {
    let asset = RenderedAsset {
        relative_path: ".something/launcher.sh",
        contents: "exec\n".into(),
        mode: Some(0o755),
    };
    // Same content, but mode 0o644 instead of 0o755.
    assert_eq!(
        byte_equal_asset_status(&asset, Some("exec\n"), Some(0o644)),
        AssetStatus::Drifted
    );
    assert_eq!(
        byte_equal_asset_status(&asset, Some("exec\n"), Some(0o755)),
        AssetStatus::Present
    );
}

#[test]
fn claude_settings_status_present_after_merge_round_trip() {
    let merged = merge_claude_settings_text(None, false)
        .expect("ok")
        .expect("rendered");
    assert_eq!(
        ha::claude_settings_status(Some(&merged.rendered)),
        AssetStatus::Present
    );
}

#[test]
fn claude_settings_status_missing_when_no_file() {
    assert_eq!(ha::claude_settings_status(None), AssetStatus::Missing);
}

#[test]
fn claude_settings_status_drifted_when_managed_entry_was_changed() {
    let merged = merge_claude_settings_text(None, false)
        .expect("ok")
        .expect("rendered");
    let mutated = merged.rendered.replacen("on-session-start", "tampered", 1);
    assert_eq!(
        ha::claude_settings_status(Some(&mutated)),
        AssetStatus::Drifted
    );
}

#[test]
fn codex_hooks_status_round_trip() {
    let merged = merge_codex_hooks_text(None, false)
        .expect("ok")
        .expect("rendered");
    assert_eq!(
        ha::codex_hooks_status(Some(&merged.rendered)),
        AssetStatus::Present
    );
    assert_eq!(ha::codex_hooks_status(None), AssetStatus::Missing);
    let mutated = merged.rendered.replacen("on-session-start", "tampered", 1);
    assert_eq!(ha::codex_hooks_status(Some(&mutated)), AssetStatus::Drifted);
}

#[test]
fn codex_config_status_present_only_when_feature_enabled() {
    assert_eq!(
        ha::codex_config_status(Some("[features]\nhooks = true\n")),
        AssetStatus::Present
    );
    assert_eq!(
        ha::codex_config_status(Some("[features]\nhooks = false\n")),
        AssetStatus::Drifted
    );
    assert_eq!(
        ha::codex_config_status(Some("[features]\ncodex_hooks = true\n")),
        AssetStatus::Drifted
    );
    assert_eq!(
        ha::codex_config_status(Some("not toml ===")),
        AssetStatus::Drifted
    );
    assert_eq!(ha::codex_config_status(None), AssetStatus::Missing);
}

#[test]
fn asset_status_dispatches_to_per_path_strategy() {
    // Merge-aware targets must be dispatched by path. Their status is
    // derived from the existing on-disk body, not from byte equality with
    // the RenderedAsset template supplied by the caller.
    let merged = merge_claude_settings_text(None, false)
        .expect("ok")
        .expect("rendered");
    let asset = RenderedAsset {
        relative_path: ha::CLAUDE_TARGET_SETTINGS,
        contents: "not the comparison source".into(),
        mode: None,
    };
    assert_eq!(
        asset_status(&asset, Some(&merged.rendered), None),
        AssetStatus::Present
    );

    let codex_config = RenderedAsset {
        relative_path: ha::CODEX_TARGET_CONFIG,
        contents: "not the comparison source".into(),
        mode: None,
    };
    assert_eq!(
        asset_status(&codex_config, Some("[features]\nhooks = true\n"), None),
        AssetStatus::Present
    );

    let merged_hooks = merge_codex_hooks_text(None, false)
        .expect("ok")
        .expect("rendered");
    let codex_hooks = RenderedAsset {
        relative_path: ha::CODEX_TARGET_HOOKS,
        contents: "not the comparison source".into(),
        mode: None,
    };
    assert_eq!(
        asset_status(&codex_hooks, Some(&merged_hooks.rendered), None),
        AssetStatus::Present
    );

    // For an arbitrary path, asset_status falls back to byte-equal.
    let other = RenderedAsset {
        relative_path: ".something/other",
        contents: "abc".into(),
        mode: None,
    };
    assert_eq!(
        asset_status(&other, Some("abc"), None),
        AssetStatus::Present
    );
    assert_eq!(
        asset_status(&other, Some("xyz"), None),
        AssetStatus::Drifted
    );
}

#[test]
fn aggregate_status_pins_drift_dominates_missing_dominates_present() {
    assert_eq!(
        aggregate_status([AssetStatus::Present, AssetStatus::Present]),
        AssetStatus::Present
    );
    assert_eq!(
        aggregate_status([AssetStatus::Present, AssetStatus::Missing]),
        AssetStatus::Missing
    );
    assert_eq!(
        aggregate_status([AssetStatus::Missing, AssetStatus::Drifted]),
        AssetStatus::Drifted
    );
    assert_eq!(
        aggregate_status(std::iter::empty::<AssetStatus>()),
        AssetStatus::NotApplicable
    );
}

// ============================================================================
// FileAction
// ============================================================================

#[test]
fn combine_actions_pins_priority() {
    assert_eq!(
        combine_actions(FileAction::AlreadyPresent, FileAction::AlreadyPresent),
        FileAction::AlreadyPresent
    );
    assert_eq!(
        combine_actions(FileAction::AlreadyPresent, FileAction::Installed),
        FileAction::Installed
    );
    assert_eq!(
        combine_actions(FileAction::Installed, FileAction::AlreadyPresent),
        FileAction::Installed
    );
    assert_eq!(
        combine_actions(FileAction::Installed, FileAction::Updated),
        FileAction::Updated
    );
    assert_eq!(
        combine_actions(FileAction::Updated, FileAction::AlreadyPresent),
        FileAction::Updated
    );
}

// ============================================================================
// End-to-end IO smoke test
//
// Exercises the rendered/merged shape against real filesystem reads to
// catch any drift between the pure-data API and what callers will
// actually do. The IO is the caller's responsibility; this test plays
// that role for one happy path.
// ============================================================================

#[test]
fn end_to_end_apply_claude_settings_writes_then_reports_present() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let target = tmp.path().join(ha::CLAUDE_TARGET_SETTINGS);
    std::fs::create_dir_all(target.parent().unwrap()).unwrap();

    // First pass: file doesn't exist → merge from None.
    let existing = match std::fs::read_to_string(&target) {
        Ok(s) => Some(s),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
        Err(err) => panic!("read: {err}"),
    };
    let merged: MergedFile = merge_claude_settings_text(existing.as_deref(), false)
        .expect("ok")
        .expect("rendered");
    std::fs::write(&target, &merged.rendered).unwrap();

    // Second pass: file matches the merged shape → status is Present and
    // the merge re-runs to the same content (idempotence).
    let on_disk = std::fs::read_to_string(&target).unwrap();
    assert_eq!(
        ha::claude_settings_status(Some(&on_disk)),
        AssetStatus::Present
    );
    let remerged = merge_claude_settings_text(Some(&on_disk), false)
        .expect("ok")
        .expect("rendered");
    assert_eq!(remerged.rendered, on_disk);
}
