"""Tests for tools/skill_ledger.py — per-mutation audit ledger + rollback.

Covers tracker #79686 P3: ledger entries on patch/edit/delete/archive, blob
dedupe, single-entry rollback (incl. fail-closed safety capture), actor
tagging, and the skills.ledger config gate.

The first four tests are adapted from PR #50261 by @yu-xin-c (autonomous
skill history), reshaped for the all-actor JSONL ledger design.
"""

import hashlib
import json
import os
import time
from pathlib import Path

import pytest


VALID_SKILL_CONTENT = """---
name: my-skill
description: test skill
---

# My Skill

Original body.
"""


@pytest.fixture
def ledger_env(tmp_path, monkeypatch):
    """Isolated HERMES_HOME + skills dir for skill_manage and the ledger."""
    from agent import skill_utils
    from tools import skill_ledger, skill_manager_tool, skill_usage

    home = tmp_path / "home"
    skills_dir = home / "skills"
    skills_dir.mkdir(parents=True)

    monkeypatch.setattr(skill_ledger, "get_hermes_home", lambda: home)
    monkeypatch.setattr(skill_usage, "get_hermes_home", lambda: home)
    monkeypatch.setattr(skill_manager_tool, "SKILLS_DIR", skills_dir)
    monkeypatch.setattr(skill_utils, "get_all_skills_dirs", lambda: [skills_dir])
    return {"home": home, "skills": skills_dir}


def _create(name="my-skill", content=VALID_SKILL_CONTENT):
    from tools.skill_manager_tool import skill_manage

    return json.loads(skill_manage(action="create", name=name, content=content))


# ---------------------------------------------------------------------------
# Adapted from PR #50261 (@yu-xin-c)
# ---------------------------------------------------------------------------


def test_background_review_patch_ledgers_and_rolls_back(ledger_env, monkeypatch):
    """A curator-pass patch lands in the ledger tagged 'curator', and a
    single-entry rollback restores the exact pre-patch content."""
    from tools import skill_ledger
    from tools.skill_manager_tool import skill_manage
    from tools.skill_provenance import (
        BACKGROUND_REVIEW,
        reset_current_write_origin,
        set_current_write_origin,
    )
    from tools.skill_manager_guards import mark_background_review_skill_read

    token = set_current_write_origin(BACKGROUND_REVIEW)
    try:
        # Created under the review fork → marked created_by: agent, so the
        # curator pass is allowed to patch it (curator invariant unchanged).
        assert _create()["success"] is True
        skill_md = ledger_env["skills"] / "my-skill" / "SKILL.md"
        original = skill_md.read_text(encoding="utf-8")
        mark_background_review_skill_read(skill_md)
        patched = json.loads(
            skill_manage(
                action="patch",
                name="my-skill",
                old_string="Original body.",
                new_string="Updated body.",
            )
        )
    finally:
        reset_current_write_origin(token)

    assert patched["success"] is True
    assert "Updated body." in skill_md.read_text(encoding="utf-8")

    rows = skill_ledger.list_entries(skill="my-skill")
    patch_rows = [r for r in rows if r["action"] == "patch"]
    assert len(patch_rows) == 1
    entry = patch_rows[0]
    assert entry["actor"] == "curator"
    assert any(i["path"].endswith("SKILL.md") for i in entry["before"])

    ok, msg = skill_ledger.rollback_entry(entry["id"])
    assert ok is True, msg
    assert skill_md.read_text(encoding="utf-8") == original


def test_foreground_patch_is_ledgered_as_agent(ledger_env):
    """Foreground skill_manage patches are ledgered too (all-actor design —
    unlike #50261's autonomous-only history) and tagged 'agent'."""
    from tools import skill_ledger
    from tools.skill_manager_tool import skill_manage

    assert _create()["success"] is True
    patched = json.loads(
        skill_manage(
            action="patch",
            name="my-skill",
            old_string="Original body.",
            new_string="Updated body.",
        )
    )
    assert patched["success"] is True

    rows = [r for r in skill_ledger.list_entries(skill="my-skill") if r["action"] == "patch"]
    assert len(rows) == 1
    assert rows[0]["actor"] == "agent"


def test_rollback_refuses_paths_outside_hermes_home(ledger_env):
    """A hand-edited ledger entry pointing outside HERMES_HOME must not
    become a write-anywhere primitive."""
    from tools import skill_ledger

    entry_id = skill_ledger.append_entry(
        "patch",
        "evil",
        before=[{"path": "/etc/passwd", "sha256": "0" * 64}],
        after=[],
    )
    assert entry_id is not None
    ok, msg = skill_ledger.rollback_entry(entry_id)
    assert ok is False
    assert "outside" in msg


def test_missing_blob_aborts_rollback_before_any_change(ledger_env):
    from tools import skill_ledger

    assert _create()["success"] is True
    skill_md = ledger_env["skills"] / "my-skill" / "SKILL.md"
    entry_id = skill_ledger.append_entry(
        "patch",
        "my-skill",
        before=[{"path": str(skill_md), "sha256": "a" * 64}],
        after=[],
    )
    current = skill_md.read_bytes()
    ok, msg = skill_ledger.rollback_entry(entry_id)
    assert ok is False
    assert "missing blob" in msg
    assert skill_md.read_bytes() == current


# ---------------------------------------------------------------------------
# New-design coverage
# ---------------------------------------------------------------------------


def test_ledger_entry_on_edit_and_delete(ledger_env):
    from tools import skill_ledger
    from tools.skill_manager_tool import skill_manage

    assert _create()["success"] is True
    edited = json.loads(
        skill_manage(
            action="edit",
            name="my-skill",
            content=VALID_SKILL_CONTENT.replace("Original body.", "Edited body."),
        )
    )
    assert edited["success"] is True
    deleted = json.loads(
        skill_manage(action="delete", name="my-skill", absorbed_into="")
    )
    assert deleted["success"] is True

    actions = [r["action"] for r in skill_ledger.list_entries(skill="my-skill")]
    assert actions == ["delete", "edit", "create"]  # newest first

    delete_entry = skill_ledger.list_entries(skill="my-skill")[0]
    # Delete intent recorded: explicit prune (absorbed_into="") + hard delete.
    assert delete_entry["evidence"]["absorbed_into"] == ""
    assert delete_entry["evidence"]["archived"] is False
    # Before-state captured, after empty (skill gone).
    assert delete_entry["before"]
    assert delete_entry["after"] == []


def test_deleted_skill_recoverable_from_ledger(ledger_env):
    """A foreground hard delete stays a hard delete — but the ledger entry
    can restore the skill's files from blobs."""
    from tools import skill_ledger
    from tools.skill_manager_tool import skill_manage

    assert _create()["success"] is True
    skill_md = ledger_env["skills"] / "my-skill" / "SKILL.md"
    original = skill_md.read_bytes()

    assert json.loads(skill_manage(action="delete", name="my-skill"))["success"]
    assert not skill_md.exists()

    entry = skill_ledger.list_entries(skill="my-skill")[0]
    ok, msg = skill_ledger.rollback_entry(entry["id"])
    assert ok is True, msg
    assert skill_md.read_bytes() == original


def test_archive_lands_in_ledger_with_curator_actor(ledger_env, monkeypatch):
    from tools import skill_ledger, skill_usage

    assert _create()["success"] is True
    # Curator auto-transition path tags the actor explicitly.
    tok = skill_ledger.set_ledger_actor("curator")
    try:
        ok, msg = skill_usage.archive_skill("my-skill")
    finally:
        skill_ledger.reset_ledger_actor(tok)
    assert ok, msg

    rows = [r for r in skill_ledger.list_entries(skill="my-skill") if r["action"] == "archive"]
    assert len(rows) == 1
    assert rows[0]["actor"] == "curator"
    assert rows[0]["before"] and rows[0]["after"]

    # And restore is ledgered as well.
    ok, msg = skill_usage.restore_skill("my-skill")
    assert ok, msg
    assert any(
        r["action"] == "restore" for r in skill_ledger.list_entries(skill="my-skill")
    )


def test_blob_dedupe_same_content_one_blob(ledger_env):
    from tools import skill_ledger

    d = ledger_env["skills"] / "dedupe-src"
    d.mkdir()
    (d / "a.md").write_text("identical content", encoding="utf-8")
    (d / "b.md").write_text("identical content", encoding="utf-8")

    manifest = skill_ledger.snapshot_paths(d)
    assert len(manifest) == 2
    hashes = {m["sha256"] for m in manifest}
    assert len(hashes) == 1  # same content → same hash
    blobs = list(skill_ledger.blobs_dir().iterdir())
    assert len(blobs) == 1  # → one blob on disk


def test_snapshot_paths_skips_transient_dirs(ledger_env):
    """Transient local artifacts (venv, node_modules, caches, .git) never reach
    the manifest or the blob store — sweeping them in grows the blob dir
    unboundedly on real installs (#107539)."""
    from tools import skill_ledger

    d = ledger_env["skills"] / "has-venv"
    d.mkdir()
    for rel, body in (("SKILL.md", "# skill"), ("scripts/run.py", "print('hi')"),
                      ("node_modules/pkg/index.js", "junk"), ("venv/bin/python", "junk"),
                      ("__pycache__/run.cpython-311.pyc", "junk"), (".git/config", "junk")):
        p = d / rel
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(body, encoding="utf-8")

    manifest = skill_ledger.snapshot_paths(d)
    rel = {str(Path(i["path"]).relative_to(d)) for i in manifest}
    assert rel == {"SKILL.md", os.path.join("scripts", "run.py")}

    # None of the transient content was stored as a blob either.
    junk_sha = hashlib.sha256(b"junk").hexdigest()
    assert junk_sha not in {p.name for p in skill_ledger.blobs_dir().iterdir()}


def test_snapshot_paths_keeps_file_named_like_transient_dir(ledger_env):
    """The filter drops files *inside* transient dirs; a plain file whose own
    name collides with one (e.g. a ``venv`` bootstrap script) is skill content."""
    from tools import skill_ledger

    d = ledger_env["skills"] / "edge"
    d.mkdir()
    (d / "SKILL.md").write_text("# skill", encoding="utf-8")
    (d / "venv").write_text("#!/bin/sh\n", encoding="utf-8")  # a FILE, not a dir

    manifest = skill_ledger.snapshot_paths(d)
    rel = {str(Path(i["path"]).relative_to(d)) for i in manifest}
    assert "venv" in rel
    assert "SKILL.md" in rel


def test_rollback_fails_closed_when_safety_capture_fails(ledger_env, monkeypatch):
    """If the pre-rollback safety ledger entry can't be written, the rollback
    must abort with nothing changed (consistent with #63366)."""
    from tools import skill_ledger
    from tools.skill_manager_tool import skill_manage

    assert _create()["success"] is True
    skill_md = ledger_env["skills"] / "my-skill" / "SKILL.md"
    patched = json.loads(
        skill_manage(
            action="patch",
            name="my-skill",
            old_string="Original body.",
            new_string="Updated body.",
        )
    )
    assert patched["success"] is True
    entry = [r for r in skill_ledger.list_entries("my-skill") if r["action"] == "patch"][0]
    current = skill_md.read_bytes()

    monkeypatch.setattr(skill_ledger, "append_entry", lambda *a, **k: None)
    ok, msg = skill_ledger.rollback_entry(entry["id"])
    assert ok is False
    assert "safety capture failed" in msg
    assert skill_md.read_bytes() == current  # nothing changed


def test_rollback_removes_files_created_by_the_mutation(ledger_env):
    from tools import skill_ledger
    from tools.skill_manager_tool import skill_manage

    assert _create()["success"] is True
    wrote = json.loads(
        skill_manage(
            action="write_file",
            name="my-skill",
            file_path="references/extra.md",
            file_content="new supporting file",
        )
    )
    assert wrote["success"] is True
    extra = ledger_env["skills"] / "my-skill" / "references" / "extra.md"
    assert extra.exists()

    entry = [r for r in skill_ledger.list_entries("my-skill") if r["action"] == "write_file"][0]
    ok, msg = skill_ledger.rollback_entry(entry["id"])
    assert ok is True, msg
    assert not extra.exists()  # created by the mutation → removed on rollback


def test_config_gate_off_no_ledger_writes(ledger_env, monkeypatch):
    from tools import skill_ledger
    from tools.skill_manager_tool import skill_manage

    import hermes_cli.config as _cfg

    off = {"skills": {"ledger": False}}
    monkeypatch.setattr(_cfg, "load_config", lambda *a, **k: off)
    monkeypatch.setattr(_cfg, "load_config_readonly", lambda *a, **k: off)

    assert _create()["success"] is True
    patched = json.loads(
        skill_manage(
            action="patch",
            name="my-skill",
            old_string="Original body.",
            new_string="Updated body.",
        )
    )
    assert patched["success"] is True  # mutation unaffected
    assert not skill_ledger.ledger_path().exists()
    assert not skill_ledger.blobs_dir().exists()


def test_ledger_failure_never_blocks_the_mutation(ledger_env, monkeypatch):
    from tools import skill_ledger
    from tools.skill_manager_tool import skill_manage

    def _boom(*a, **k):
        raise OSError("disk full")

    monkeypatch.setattr(skill_ledger, "snapshot_paths", _boom)

    assert _create()["success"] is True
    patched = json.loads(
        skill_manage(
            action="patch",
            name="my-skill",
            old_string="Original body.",
            new_string="Updated body.",
        )
    )
    assert patched["success"] is True


def test_list_entries_filtering_and_limit(ledger_env):
    from tools import skill_ledger

    for i in range(5):
        skill_ledger.append_entry("patch", f"skill-{i % 2}", before=[], after=[])
    assert len(skill_ledger.list_entries(limit=3)) == 3
    only_zero = skill_ledger.list_entries(skill="skill-0")
    assert len(only_zero) == 3
    assert all(r["skill"] == "skill-0" for r in only_zero)


def test_user_actor_override(ledger_env):
    from tools import skill_ledger

    tok = skill_ledger.set_ledger_actor("user")
    try:
        entry_id = skill_ledger.append_entry("archive", "some-skill")
    finally:
        skill_ledger.reset_ledger_actor(tok)
    entry = skill_ledger.get_entry(entry_id)
    assert entry["actor"] == "user"


# ---------------------------------------------------------------------------
# Package-completeness fill from the newest curator backup (issue #96962)
# ---------------------------------------------------------------------------


def _write_skills_tarball(home: Path, files: dict, stamp: str = "2026-08-01T00-00-00Z"):
    """Write a curator-shaped ``skills.tar.gz`` under *home* (arcnames are
    relative to skills/, exactly like agent.curator_backup.snapshot_skills)."""
    import io
    import tarfile

    snap = home / "skills" / ".curator_backups" / stamp
    snap.mkdir(parents=True, exist_ok=True)
    tar_path = snap / "skills.tar.gz"
    with tarfile.open(tar_path, "w:gz") as tf:
        for rel, content in files.items():
            data = content.encode("utf-8") if isinstance(content, str) else content
            info = tarfile.TarInfo(name=rel)
            info.size = len(data)
            tf.addfile(info, io.BytesIO(data))
    return tar_path


def test_delete_after_rehome_ledgers_full_package_from_backup(ledger_env):
    """The incident shape (#96962): consolidation re-homes references/ out of
    the tree, then deletes. The delete entry must still capture the support
    file from the newest curator backup, and rollback must restore both."""
    from tools import skill_ledger
    from tools.skill_manager_tool import skill_manage

    assert _create()["success"] is True
    extra = ledger_env["skills"] / "my-skill" / "references" / "extra.md"
    wrote = json.loads(skill_manage(
        action="write_file",
        name="my-skill",
        file_path="references/extra.md",
        file_content="roadmap body",
    ))
    assert wrote["success"] is True

    # The pre-curator-run snapshot, taken while the package was whole.
    skill_md = ledger_env["skills"] / "my-skill" / "SKILL.md"
    _write_skills_tarball(
        ledger_env["home"],
        {
            "my-skill/SKILL.md": skill_md.read_text(encoding="utf-8"),
            "my-skill/references/extra.md": "roadmap body",
        },
    )

    # Re-home: the support file leaves the tree before the delete.
    extra.unlink()
    extra.parent.rmdir()

    deleted = json.loads(skill_manage(action="delete", name="my-skill"))
    assert deleted["success"] is True

    delete_entry = [
        r for r in skill_ledger.list_entries(skill="my-skill")
        if r["action"] == "delete"
    ][0]
    before_names = {Path(i["path"]).name for i in delete_entry["before"]}
    assert "SKILL.md" in before_names
    assert "extra.md" in before_names, (
        "delete ledger captured only SKILL.md after the support files were "
        "re-homed — rollback would restore a hollow skill (#96962)"
    )

    ok, msg = skill_ledger.rollback_entry(delete_entry["id"])
    assert ok is True, msg
    assert skill_md.is_file()
    assert extra.is_file()
    assert extra.read_text(encoding="utf-8") == "roadmap body"


def test_rollback_historical_hollow_entry_restores_full_package(ledger_env):
    """Entries recorded BEFORE this fix (files: 1) still restore the whole
    package: rollback-time fill from the newest curator backup."""
    from tools import skill_ledger

    skill_dir = ledger_env["skills"] / "my-skill"
    skill_dir.mkdir()
    skill_md = skill_dir / "SKILL.md"
    skill_md.write_text(VALID_SKILL_CONTENT, encoding="utf-8")
    _write_skills_tarball(
        ledger_env["home"],
        {
            "my-skill/SKILL.md": VALID_SKILL_CONTENT,
            "my-skill/references/roadmap.md": "week 1",
        },
    )
    # The mutation that made the entry: package gone, only SKILL.md captured.
    skill_md.unlink()
    skill_dir.rmdir()

    entry_id = skill_ledger.append_entry(
        "delete",
        "my-skill",
        before=[{"path": str(skill_md), "sha256": skill_ledger._store_blob(
            VALID_SKILL_CONTENT.encode("utf-8")
        )}],
        after=[],
    )
    assert entry_id is not None

    ok, msg = skill_ledger.rollback_entry(entry_id)
    assert ok is True, msg
    roadmap = skill_dir / "references" / "roadmap.md"
    assert skill_md.is_file()
    assert roadmap.is_file(), "hollow rollback: support file not restored"
    assert roadmap.read_text(encoding="utf-8") == "week 1"


def test_delete_rollback_without_backup_still_works(ledger_env):
    """No curator backup present: the fill degrades to the old behavior and
    must not break the plain delete -> rollback round trip."""
    from tools import skill_ledger
    from tools.skill_manager_tool import skill_manage

    assert _create()["success"] is True
    skill_md = ledger_env["skills"] / "my-skill" / "SKILL.md"

    deleted = json.loads(skill_manage(action="delete", name="my-skill"))
    assert deleted["success"] is True
    delete_entry = [
        r for r in skill_ledger.list_entries(skill="my-skill")
        if r["action"] == "delete"
    ][0]
    assert {Path(i["path"]).name for i in delete_entry["before"]} == {"SKILL.md"}

    ok, msg = skill_ledger.rollback_entry(delete_entry["id"])
    assert ok is True, msg
    assert skill_md.read_text(encoding="utf-8") == VALID_SKILL_CONTENT


def test_backup_fill_does_not_clobber_disk_hash(ledger_env):
    """Disk state wins: a live SKILL.md that differs from the backup copy is
    captured with the LIVE hash; the backup only fills missing paths."""
    from tools import skill_ledger

    skill_dir = ledger_env["skills"] / "my-skill"
    skill_dir.mkdir()
    skill_md = skill_dir / "SKILL.md"
    live = VALID_SKILL_CONTENT.replace("Original body.", "Live body.")
    skill_md.write_text(live, encoding="utf-8", newline="\n")
    _write_skills_tarball(
        ledger_env["home"],
        {
            "my-skill/SKILL.md": VALID_SKILL_CONTENT,
            "my-skill/references/extra.md": "from tar",
        },
    )

    captured = skill_ledger.snapshot_paths(skill_dir, complete_package=True)
    by_name = {Path(i["path"]).name: i["sha256"] for i in captured}
    live_hash = skill_ledger._store_blob(live.encode("utf-8"))
    tar_hash = skill_ledger._store_blob(VALID_SKILL_CONTENT.encode("utf-8"))
    assert by_name["SKILL.md"] == live_hash, "disk hash must win over backup"
    assert by_name["SKILL.md"] != tar_hash
    assert by_name["extra.md"] == skill_ledger._store_blob(b"from tar")


def test_backup_fill_ignores_tar_path_traversal(ledger_env):
    """Fill runs AND malicious members are rejected: a legitimate missing
    file is restored while members escaping the package prefix (absolute,
    ..) are never filled. Both assertions matter — the positive one keeps
    this test honest (a silently inert fill would pass a negatives-only
    check), the negative one pins the traversal defense."""
    from tools import skill_ledger

    skill_dir = ledger_env["skills"] / "my-skill"
    skill_dir.mkdir()
    (skill_dir / "SKILL.md").write_text(VALID_SKILL_CONTENT, encoding="utf-8")
    _write_skills_tarball(
        ledger_env["home"],
        {
            "my-skill/SKILL.md": VALID_SKILL_CONTENT,
            "my-skill/references/legit.md": "legit body",
            "../evil.md": "nope",
            "my-skill/../outside.md": "nope",
        },
    )

    captured = skill_ledger.snapshot_paths(skill_dir, complete_package=True)
    paths = [i["path"] for i in captured]
    # The legitimate missing file WAS filled — proof the fill is live.
    assert any(Path(p).as_posix().endswith("references/legit.md") for p in paths), (
        "package fill did not restore the missing support file"
    )
    # Malicious members are not.
    assert not any(p.endswith("evil.md") or p.endswith("outside.md") for p in paths)

import pytest


def _append_padded(skill_ledger, action: str, pad: str, n: int = 1) -> None:
    """Append *n* entries padded with evidence text so the ledger file grows fast."""
    for _ in range(n):
        skill_ledger.append_entry(action, "my-skill", before=[], after=[], evidence={"pad": pad})


def test_auto_compact_triggers_at_threshold(ledger_env, monkeypatch):
    """Crossing skills.ledger_max_bytes rewrites the ledger through the delta
    dedup: legacy rows that still carry identical before/after manifests (written
    before append-time ``_delta`` existed) shrink to nothing while ids and entry
    order survive, and nothing is trimmed when dedup alone reaches the cap."""
    import json

    from tools import skill_ledger

    import hermes_cli.config as _cfg

    cap = {"skills": {"ledger_max_bytes": 8192}}
    monkeypatch.setattr(_cfg, "load_config", lambda *a, **k: cap)
    monkeypatch.setattr(_cfg, "load_config_readonly", lambda *a, **k: cap)

    first_id = skill_ledger.append_entry("patch", "my-skill", before=[], after=[])
    template = json.loads(skill_ledger.ledger_path().read_text().splitlines()[0])
    with skill_ledger.ledger_path().open("a", encoding="utf-8") as fh:
        for i in range(3):  # legacy pre-delta rows: identical fat manifests on both sides
            fat = [{"path": f"my-skill/f{i}j{j}.md", "sha256": "a" * 64} for j in range(40)]
            row = dict(template, id=f"legacy{i}", before=fat, after=list(fat))
            fh.write(json.dumps(row) + "\n")
    assert skill_ledger.ledger_path().stat().st_size > 8192

    last_id = skill_ledger.append_entry("patch", "my-skill", before=[], after=[])

    # the maintenance sweep fired on that append: the file is back under the cap
    assert skill_ledger.ledger_path().stat().st_size <= 8192
    rows = skill_ledger.list_entries()
    assert {r["id"] for r in rows} == {first_id, last_id, "legacy0", "legacy1", "legacy2"}, (
        "dedup alone must reach the cap — nothing trimmed, ids survive"
    )
    assert all(r["before"] == [] and r["after"] == [] for r in rows), (
        "identical manifests must be dropped by compaction"
    )


def test_trim_oldest_when_still_over_cap(ledger_env, monkeypatch):
    """When compaction alone cannot reach the cap (every entry genuinely
    differs), the oldest lines are dropped — whatever their shape — until the
    file fits under the LOW-WATER mark (80% of the cap, so the next append does
    not immediately re-trigger the sweep). The newest entry survives, and lines
    in the retained tail are never parsed or rewritten: a malformed last line
    survives verbatim."""
    from tools import skill_ledger

    import hermes_cli.config as _cfg

    cap = {"skills": {"ledger_max_bytes": 0}}  # no sweeps while seeding
    monkeypatch.setattr(_cfg, "load_config", lambda *a, **k: cap)
    monkeypatch.setattr(_cfg, "load_config_readonly", lambda *a, **k: cap)

    newest_id = None
    for i in range(5):
        before = [{"path": f"my-skill/old{i}.md", "sha256": f"{i}" * 64}]
        after = [{"path": f"my-skill/new{i}.md", "sha256": f"{i + 1}" * 64}]
        newest_id = skill_ledger.append_entry(
            "edit", "my-skill", before=before, after=after,
            evidence={"pad": "y" * 2048})
    with open(skill_ledger.ledger_path(), "a", encoding="utf-8") as fh:
        fh.write("{not json at all\n")
    assert skill_ledger.ledger_path().stat().st_size > 8192

    cap["skills"]["ledger_max_bytes"] = 8192
    skill_ledger._maintain_size()

    rows = skill_ledger.list_entries()
    assert len(rows) < 5, "oldest entries must be trimmed when compaction is not enough"
    assert rows[0]["id"] == newest_id, "the newest entry always survives"
    # malformed lines are never parsed away — they stay in the file verbatim
    raw = skill_ledger.ledger_path().read_text(encoding="utf-8")
    assert "{not json at all" in raw
    # A sweep that fires does not stop at the cap but at the low-water mark ...
    assert skill_ledger.ledger_path().stat().st_size <= int(8192 * 0.8)
    # ... so the next append rides under the cap without paying compact+trim+gc again.
    compactions = []
    monkeypatch.setattr(skill_ledger, "compact_ledger",
                        lambda *a, **k: compactions.append(1) or (0, 0, 0))
    skill_ledger.append_entry(
        "edit", "my-skill", before=[{"path": "my-skill/z.md", "sha256": "a" * 64}],
        after=[{"path": "my-skill/z2.md", "sha256": "b" * 64}], evidence={"pad": "z" * 1000})
    assert compactions == [], "an append under the cap must not re-run the sweep"
    assert skill_ledger.ledger_path().stat().st_size <= 8192
    assert "{not json at all" in skill_ledger.ledger_path().read_text(encoding="utf-8")
    # U+2028 inside a row (ensure_ascii=False leaves it unescaped) is not a row boundary for the
    # trim: a cap that fits only the newest row keeps that row byte-for-byte, not its second half.
    u_row = (json.dumps({"id": "u2028", "skill": "my-skill", "action": "edit",
                         "evidence": {"note": "line one\u2028line two"}}, ensure_ascii=False) + "\n").encode("utf-8")
    with open(skill_ledger.ledger_path(), "ab") as fh:
        fh.write(u_row)
    assert skill_ledger._trim_oldest(len(u_row) + 8) >= 1
    assert skill_ledger.ledger_path().read_bytes() == u_row, "a retained row containing U+2028 survives intact"


def test_concurrent_appends_never_lose_a_middle_row(ledger_env, monkeypatch):
    """Two writers appending while the maintenance sweep fires on (almost) every append:
    every row each writer appended is either still in the ledger or was trimmed
    oldest-first — never silently lost from the middle of a writer's sequence. The sweep's
    read → ``os.replace`` must run under the same ``.locks/ledger.lock`` as the O_APPEND
    write, or an append landing on the replaced inode vanishes (and ``gc_blobs`` would then
    delete its blobs). The race is forced, not hoped for: writer A's first sweep pauses
    between reading the ledger and replacing it until writer B has appended (or, when the
    lock correctly blocks B, until a generous bound expires — green never depends on timing)."""
    import threading

    from tools import skill_ledger

    import hermes_cli.config as _cfg

    cap = {"skills": {"ledger_max_bytes": 4096}}  # padded rows ~600 B: a trim on nearly every append
    monkeypatch.setattr(_cfg, "load_config", lambda *a, **k: cap)
    monkeypatch.setattr(_cfg, "load_config_readonly", lambda *a, **k: cap)

    n, ids = 40, {"A": [], "B": []}
    b_go, b_done = threading.Event(), threading.Event()
    real_rewrite = skill_ledger._rewrite_ledger

    def paused_rewrite(path, lines, op):
        if threading.current_thread().name == "A" and not b_go.is_set():
            b_go.set()             # A has read the ledger; let B append now ...
            b_done.wait(1.0)       # ... and give it every chance to land before the replace
        return real_rewrite(path, lines, op)

    monkeypatch.setattr(skill_ledger, "_rewrite_ledger", paused_rewrite)
    dropped, real_trim = [], skill_ledger._trim_oldest
    monkeypatch.setattr(skill_ledger, "_trim_oldest",
                        lambda max_bytes: dropped.append(real_trim(max_bytes)) or dropped[-1])

    def writer(k: str) -> None:
        if k == "B":
            b_go.wait(10.0)
        for i in range(n):
            before = [{"path": f"my-skill/{k}-{i}.md", "sha256": "a" * 64}]
            after = [{"path": f"my-skill/{k}-{i}.md", "sha256": f"{i % 10}" * 64}]
            ids[k].append(skill_ledger.append_entry(
                "edit", "my-skill", before=before, after=after, evidence={"pad": "x" * 500}))
            if k == "B":
                b_done.set()

    seeds = 8  # seed over the cap so A's very first append sweeps
    for _ in range(seeds):
        skill_ledger.append_entry("edit", "my-skill", before=[{"path": "s", "sha256": "0" * 64}],
                                  after=[{"path": "s", "sha256": "1" * 64}], evidence={"pad": "x" * 500})
    # Unreferenced blobs: a fresh one is another process's in-flight capture (row not appended yet)
    # and must survive every sweep's blob GC; one older than the grace window is garbage and goes.
    fresh, aged = skill_ledger._store_blob(b"in-flight"), skill_ledger._store_blob(b"stale orphan")
    os.utime(skill_ledger.blobs_dir() / aged, (time.time() - 7200, time.time() - 7200))
    threads = [threading.Thread(target=writer, args=(k,), name=k) for k in ids]
    for t in threads:
        t.start()
    for t in threads:
        t.join()

    assert b_go.is_set(), "writer A's first append must have swept (test precondition)"
    assert all(ids["A"]) and all(ids["B"]) and len(ids["A"]) == len(ids["B"]) == n, "every append reported success"
    present = [json.loads(line)["id"] for line in
               skill_ledger.ledger_path().read_text(encoding="utf-8").splitlines() if line.strip()]
    assert present, "the newest row always survives a trim"
    assert (skill_ledger.blobs_dir() / fresh).exists() and not (skill_ledger.blobs_dir() / aged).exists()
    assert len(present) == seeds + 2 * n - sum(dropped), (
        "every row is either in the ledger or was counted as trimmed — none silently lost"
    )
    for k, seq in ids.items():
        survivors = [i for i in seq if i in set(present)]
        assert survivors == seq[len(seq) - len(survivors):], (
            f"writer {k}: rows missing from the middle — a concurrent sweep dropped an append"
        )
