"""Invariant for the non-secret atomic JSON writers that used to inline ``utils._atomic_write``.

Contract under test for ``gateway/session_persistence``, ``cron/suggestions`` and
``agent/shell_hooks``: a failed replace leaves the previous file byte-identical AND leaves no temp
file behind (the interrupt-safe cleanup only the canonical helper guarantees). A hand-rolled copy
that skips the cleanup, or writes through the target instead of a sibling temp, fails this.
"""

from __future__ import annotations

import json
from pathlib import Path

import pytest


def _failing_replace(tmp, target):
    raise OSError("simulated disk full")


def _leftovers(directory: Path, keep: str) -> list[str]:
    return sorted(p.name for p in directory.iterdir() if p.name != keep)


@pytest.fixture
def broken_replace(monkeypatch):
    import utils

    monkeypatch.setattr(utils, "atomic_replace", _failing_replace)


def test_sessions_json_failed_replace_keeps_old_bytes_and_no_temp(tmp_path, monkeypatch, broken_replace):
    from gateway.session_persistence import SessionPersistenceMixin

    sessions_dir = tmp_path / "sessions"
    sessions_dir.mkdir()
    target = sessions_dir / "sessions.json"
    target.write_text('{"old": true}', encoding="utf-8")
    store = SessionPersistenceMixin()
    store.sessions_dir = sessions_dir
    with pytest.raises(OSError):
        store._save_sessions_json({"k": "v"})
    assert target.read_text(encoding="utf-8") == '{"old": true}'
    assert _leftovers(sessions_dir, "sessions.json") == []


def test_suggestions_failed_replace_keeps_old_bytes_and_no_temp(tmp_path, monkeypatch, broken_replace):
    from cron import suggestions

    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    target = suggestions._current_suggestions_file()
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text('{"old": true}', encoding="utf-8")
    with pytest.raises(OSError):
        suggestions._save_raw([{"id": 1}])
    assert target.read_text(encoding="utf-8") == '{"old": true}'
    assert _leftovers(target.parent, target.name) == []


def test_shell_hooks_allowlist_survives_failed_replace_without_temp(tmp_path, monkeypatch, broken_replace):
    from agent import shell_hooks

    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
    target = shell_hooks.allowlist_path()
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(json.dumps({"approvals": []}), encoding="utf-8")
    shell_hooks.save_allowlist({"approvals": [{"event": "a", "command": "x"}]})  # logs, never raises
    assert json.loads(target.read_text(encoding="utf-8")) == {"approvals": []}
    assert _leftovers(target.parent, target.name) == []


def test_surrogate_escaped_strings_round_trip_through_atomic_json_write(tmp_path):
    """A non-UTF-8 cwd/argv (``os.fsdecode`` → lone surrogate) must be persisted, not raise.

    Breadcrumbs, the shell-hook allowlist and the active-sessions ledger all persist paths and
    guard only ``OSError``; a ``UnicodeEncodeError`` (a ValueError) escaping the canonical writer
    silently stopped those writes.
    """
    from utils import atomic_json_write

    payload = {"cwd": "a\udcffb", "plain": "caf\u00e9"}
    target = tmp_path / "crumbs" / "crumb.json"
    atomic_json_write(target, payload)
    assert json.loads(target.read_bytes()) == payload
    assert _leftovers(target.parent, target.name) == []


@pytest.mark.platforms("linux")
def test_new_non_secret_file_follows_umask_while_secret_and_existing_modes_hold(tmp_path):
    """The writers this helper replaced created files at process umask; only ``mode=`` tightens."""
    import os
    import stat

    from utils import atomic_json_write

    old_umask = os.umask(0o022)
    try:
        fresh = tmp_path / "cache.json"
        atomic_json_write(fresh, {"a": 1})
        assert stat.S_IMODE(fresh.stat().st_mode) == 0o644, "new non-secret file must not inherit mkstemp's 0600"

        secret = tmp_path / "creds.json"
        atomic_json_write(secret, {"token": "x"}, mode=0o600)
        assert stat.S_IMODE(secret.stat().st_mode) == 0o600

        existing = tmp_path / "state.json"
        existing.write_text("{}", encoding="utf-8")
        os.chmod(existing, 0o640)
        atomic_json_write(existing, {"b": 2})
        assert stat.S_IMODE(existing.stat().st_mode) == 0o640
    finally:
        os.umask(old_umask)


@pytest.mark.platforms("linux")
def test_mkstemp_heritage_writers_keep_new_files_owner_only(tmp_path):
    """Writers that published through mkstemp on main created NEW files at 0600 regardless of umask
    (bot mailboxes, turn markers); folding them into utils must not loosen that to umask."""
    import os
    import stat

    from tools.bot_relay import _atomic_write_json
    from tui_gateway.turn_marker import _store

    old_umask = os.umask(0o022)
    try:
        relay_target = tmp_path / "relay" / "inbox.json"
        _atomic_write_json(relay_target, {"k": 1})
        marker = tmp_path / "turn-marker.json"
        _store(marker, {"sess": {"started_at": 1.0}})
        for path in (relay_target, marker):
            assert stat.S_IMODE(path.stat().st_mode) == 0o600, path
    finally:
        os.umask(old_umask)
