"""Tests for the .swarm bundle engine: skill round-trip, secret redaction, and
the zip-hardening rejections. The skills store writes to ~/.claude/skills, so we
monkeypatch it into a temp dir per test (the conftest only isolates browser
state)."""
import io
import json
import os
import zipfile

import pytest

from backend.apps.skills import skills as store
from backend.apps.swarm import closure
from backend.apps.swarm.models import EntityType
from backend.apps.swarm.redact import find_denied_keys, scrub_payload
from backend.apps.swarm.ziputil import BundleError, pack, unpack


@pytest.fixture
def skill_store(tmp_path, monkeypatch):
    d = tmp_path / "skills"
    d.mkdir()
    monkeypatch.setattr(store, "SKILLS_DIR", str(d))
    monkeypatch.setattr(store, "INDEX_PATH", str(d / ".skills_index.json"))
    return d


def p_make_skill(d, slug, name, content, description="desc"):
    (d / f"{slug}.md").write_text(content, encoding="utf-8")
    index = store.load_index()
    index[slug] = {"name": name, "description": description, "command": slug}
    store.save_index(index)


def test_skill_export_import_round_trip(skill_store):
    p_make_skill(skill_store, "my-skill", "My Skill", "# hello\nbody text")
    raw, name = closure.build_bundle(EntityType.skill, "my-skill")
    assert name == "My Skill"
    assert zipfile.is_zipfile(io.BytesIO(raw))

    sandbox, manifest, warnings = closure.stage_upload(raw, "My Skill.swarm")
    try:
        assert manifest.root.type == EntityType.skill
        root_type, root_id, created, unresolved = closure.commit(sandbox, manifest, [])
    finally:
        import shutil
        shutil.rmtree(sandbox, ignore_errors=True)

    # Original is untouched, import lands under a fresh, non-clobbering slug.
    assert root_type == EntityType.skill
    assert root_id != "my-skill"
    assert (skill_store / "my-skill.md").exists()  # original flat skill untouched
    assert (skill_store / root_id / "SKILL.md").read_text(encoding="utf-8") == "# hello\nbody text"
    assert created == {"skill": [root_id]}


def test_bare_markdown_import(skill_store):
    sandbox, manifest, warnings = closure.stage_upload(b"# Just markdown", "Cool Trick.md")
    try:
        assert manifest.root.type == EntityType.skill
        assert manifest.root.name == "Cool Trick"
        p_t, root_id, created, p_u = closure.commit(sandbox, manifest, [])
    finally:
        import shutil
        shutil.rmtree(sandbox, ignore_errors=True)
    assert (skill_store / root_id / "SKILL.md").read_text(encoding="utf-8") == "# Just markdown"


def test_content_secret_redacted_in_bundle(skill_store):
    secret = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA"
    p_make_skill(skill_store, "leaky", "Leaky", f"use this key: {secret}")
    raw, p_name = closure.build_bundle(EntityType.skill, "leaky")
    # Inspect the actual packed payload (zip entries are compressed, so grepping the raw bytes proves nothing).
    with zipfile.ZipFile(io.BytesIO(raw)) as zf:
        payload_name = next(n for n in zf.namelist() if n.endswith("payload.json"))
        payload = json.loads(zf.read(payload_name))
    assert secret not in payload["content"]
    assert "[redacted]" in payload["content"]


def test_redaction_drops_denied_keys():
    payload = {
        "name": "ok",
        "anthropic_api_key": "sk-ant-secret",
        "nested": {"openswarm_bearer_token": "abc", "keep": 1},
        "list": [{"oauth_tokens": {"x": 1}}, {"fine": 2}],
    }
    cleaned = scrub_payload(payload)
    assert find_denied_keys(cleaned) == []
    assert cleaned["name"] == "ok"
    assert cleaned["nested"]["keep"] == 1
    assert cleaned["list"][1]["fine"] == 2


def test_pack_refuses_denied_key():
    # Defense in depth: even if redaction were skipped, pack must not ship a secret.
    with pytest.raises(BundleError):
        pack({"format_version": 1}, {"bid1": {"api_key": "leak"}}, {})


def test_pack_refuses_secret_in_workspace_file():
    # A key hardcoded in app source (not .env) must not ride along; pack scans file bytes, not just payload keys.
    leak = b"const KEY = 'sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA';\n"
    with pytest.raises(BundleError):
        pack({"format_version": 1}, {"bid1": {"name": "ok"}}, {"entities/bid1/files/config.js": leak})


def test_pack_allows_clean_workspace_file():
    raw = pack({"format_version": 1}, {"bid1": {"name": "ok"}}, {"entities/bid1/files/app.js": b"export default 1"})
    assert zipfile.is_zipfile(io.BytesIO(raw))


def test_pack_export_anyway_overrides_file_scan_but_never_denied_keys():
    # User-confirmed override ships a flagged workspace FILE (trusted recipient); our own credential fields stay unexportable no matter what.
    leak = b"const KEY = 'sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA';\n"
    raw = pack({"format_version": 1}, {"bid1": {"name": "ok"}}, {"entities/bid1/files/config.js": leak}, allow_file_secrets=True)
    assert zipfile.is_zipfile(io.BytesIO(raw))
    with pytest.raises(BundleError):
        pack({"format_version": 1}, {"bid1": {"api_key": "leak"}}, {}, allow_file_secrets=True)


def test_app_export_drops_machine_env(tmp_path, monkeypatch):
    # The live .env holds the source machine's absolute paths + pinned port; it must never ride along. .env.example (portable) does.
    from backend.apps.swarm.entities import apps as appmod
    from backend.apps.outputs.models import Output

    ws = tmp_path / "ws"
    (ws / "frontend").mkdir(parents=True)
    (ws / ".env").write_text("FRONTEND_PORT=5\nOPENSWARM_TEMPLATE_BACKEND_PATH=/Users/SECRET/x\n")
    (ws / ".env.example").write_text("BACKEND_PORT=NONE\nFRONTEND_PORT=4949\n")
    (ws / "frontend" / "App.tsx").write_text("export default () => null")
    monkeypatch.setattr(appmod, "OUTPUTS_WORKSPACE_DIR", str(tmp_path))

    ex = appmod.AppExportable(Output(name="A", workspace_id="ws"))
    files = ex.files()
    assert "workspace/.env.example" in files
    assert "workspace/.env" not in files
    assert "workspace/frontend/App.tsx" in files
    assert b"/Users/SECRET" not in b"".join(files.values())


def test_workflow_sanitize_disables_schedule_and_strips_pii():
    from backend.apps.swarm.entities.workflows import sanitize_workflow
    raw = {
        "id": "wf123",
        "title": "Daily digest",
        "steps": [{"id": "s1", "text": "do thing"}],
        "schedule": {"enabled": True, "runs_count": 5, "next_run_at": "2026-01-01T00:00:00", "hour": 9},
        "permissions": [{"kind": "text", "after_minutes": 30, "phone": "+15551234567"}],
        "source_session_id": "sess1",
        "dashboard_id": "dash1",
        "last_run_status": "success",
        "mode": "agent",
        "provider": "anthropic",
    }
    out = sanitize_workflow(raw)
    # An imported workflow must not auto-run or carry the sharer's identity.
    assert out["schedule"]["enabled"] is False
    assert out["schedule"]["runs_count"] == 0
    assert out["schedule"]["hour"] == 9  # cadence shape preserved
    assert out["permissions"][0]["phone"] is None
    for dropped in ("id", "source_session_id", "dashboard_id", "last_run_status"):
        assert dropped not in out
    assert out["title"] == "Daily digest"


def test_workflow_round_trips_through_the_store(isolated_workflows_data):
    # The workflow store landed on this branch, so a workflow bundle imports into an (isolated) store: an unknown id loads as None, import_ creates a fresh row with its schedule forced OFF (so an imported workflow never auto-runs on someone else's machine), and load reads it back. Supersedes test_workflow_unavailable_on_this_branch, which dated from before the workflow store was on eric/dev.
    from backend.apps.swarm.entities.workflows import WorkflowExportable
    from backend.apps.swarm.exportable import RemapTable
    from backend.apps.workflows import storage
    assert WorkflowExportable.load("nonexistent") is None
    new_id = WorkflowExportable.import_(
        {"title": "Shared WF", "schedule": {"enabled": True}}, {}, RemapTable()
    )
    assert new_id
    loaded = WorkflowExportable.load(new_id)
    assert loaded is not None
    assert loaded.name == "Shared WF"
    # Read the persisted row back through the store's public API (not the entity's private data) to confirm the schedule was forced off on import.
    saved = storage.get_workflow(new_id)
    assert saved is not None and saved.schedule.enabled is False


def test_session_export_carries_transcript_drops_runtime_and_secrets():
    from backend.apps.swarm.entities.SessionExportable import SessionExportable
    from backend.apps.swarm.redact import scrub_payload
    data = {
        "name": "A", "provider": "anthropic", "model": "sonnet", "mode": "agent",
        "system_prompt": "hi", "allowed_tools": ["Read"],
        "messages": [
            {"id": "m1", "role": "user", "content": "private chat", "branch_id": "main"},
            {"id": "m2", "role": "assistant", "content": "token is sk-ant-abcdefghij0123456789"},
        ],
        "branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None}},
        "active_branch_id": "main",
        "tool_group_meta": {"g1": {"label": "x"}},
        "active_mcps": ["Gmail"], "cwd": "/Users/me/repo", "cost_usd": 9.9, "sdk_session_id": "x",
    }
    ex = SessionExportable("s1", "A", data)
    out = ex.serialize(None)
    # The transcript now rides along, that's the point of sharing an agent.
    assert out["messages"][0]["content"] == "private chat"
    assert out["active_branch_id"] == "main" and "main" in out["branches"]
    assert out["tool_group_meta"] == {"g1": {"label": "x"}}
    # Runtime, identity, and gate state still never leave.
    for gone in ("cwd", "active_mcps", "cost_usd", "sdk_session_id"):
        assert gone not in out
    # The closure runs scrub_payload on every payload, so a secret-shaped string sitting in the transcript is redacted before it ships.
    assert "sk-ant-" not in json.dumps(scrub_payload(out))
    reqs = ex.requirements()
    assert any(r.kind.value == "mcp_action" and r.key == "Gmail" for r in reqs)


def test_session_import_restores_transcript_without_granting_mcp(monkeypatch):
    from backend.apps.swarm.entities.SessionExportable import SessionExportable
    from backend.apps.swarm.exportable import RemapTable
    from backend.apps.agents.manager.session import session_store
    saved: dict = {}
    monkeypatch.setattr(session_store, "save_session", lambda sid, doc: saved.update({sid: doc}))
    payload = {
        "name": "A", "model": "sonnet", "mode": "agent",
        "messages": [{"id": "m1", "role": "user", "content": "hi", "branch_id": "main"}],
        "branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None}},
        "active_branch_id": "main",
        "tool_group_meta": {"g1": {"label": "x"}},
    }
    sid = SessionExportable.import_(payload, {}, RemapTable())
    doc = saved[sid]
    assert doc["messages"][0]["content"] == "hi"
    assert doc["active_branch_id"] == "main"
    assert doc["tool_group_meta"] == {"g1": {"label": "x"}}
    # The gate stays shut: a shared agent never arrives with MCP access.
    assert doc["active_mcps"] == []
    # The dashboard import re-points this; it must never be the sharer's id.
    assert doc["dashboard_id"] is None


def test_session_import_old_bundle_without_transcript(monkeypatch):
    # A bundle made before transcripts were carried has no messages; it must still import as a valid empty-history agent (single main branch), not crash.
    from backend.apps.swarm.entities.SessionExportable import SessionExportable
    from backend.apps.swarm.exportable import RemapTable
    from backend.apps.agents.manager.session import session_store
    saved: dict = {}
    monkeypatch.setattr(session_store, "save_session", lambda sid, doc: saved.update({sid: doc}))
    sid = SessionExportable.import_({"name": "Old", "model": "sonnet"}, {}, RemapTable())
    doc = saved[sid]
    assert doc["messages"] == []
    assert doc["active_branch_id"] == "main" and "main" in doc["branches"]


def test_session_load_prefers_live_memory_over_stale_disk(tmp_path, monkeypatch):
    # The freshest transcript lives in memory; a disk-only load would ship a stale one. load() must read the live session first, disk only as fallback.
    from backend.apps.agents import agent_manager as am
    from backend.apps.swarm.entities.SessionExportable import SessionExportable
    sdir = tmp_path / "sessions"
    sdir.mkdir()
    monkeypatch.setattr(am, "SESSIONS_DIR", str(sdir))
    (sdir / "s1.json").write_text(json.dumps(
        {"name": "Stale", "messages": [{"id": "old", "role": "user", "content": "old"}]}))

    class FakeSess:
        def model_dump(self, mode="json"):
            return {"name": "Live", "messages": [
                {"id": "old", "role": "user", "content": "old"},
                {"id": "new", "role": "assistant", "content": "fresh turn"},
            ]}

    monkeypatch.setattr(am.agent_manager, "sessions", {"s1": FakeSess()})
    out = SessionExportable.load("s1").serialize(None)
    assert out["name"] == "Live"          # not the stale disk copy
    assert len(out["messages"]) == 2      # the unflushed turn is included


def test_dashboard_export_import_carries_agent_cards_and_transcript(tmp_path, monkeypatch):
    # The path the single-session tests missed: a whole dashboard with agent cards + a browser card. Both agents (with their transcripts) and the browser must survive export -> import. An empty-history import is the bug the user hit ("the chats didn't even show up, let alone the history").
    import shutil
    from backend.apps.agents import agent_manager as am
    import backend.config.paths as paths
    sdir = tmp_path / "sessions"
    ddir = tmp_path / "dashboards"
    sdir.mkdir()
    ddir.mkdir()
    monkeypatch.setattr(am, "SESSIONS_DIR", str(sdir))
    monkeypatch.setattr(paths, "DASHBOARDS_DIR", str(ddir))
    monkeypatch.setattr(am.agent_manager, "sessions", {})  # nothing live -> disk path

    did, sid1, sid2, bkey = "d1", "sA", "sB", "browser-1"

    def sess(sid, name, text):
        return {
            "id": sid, "name": name, "status": "completed", "provider": "anthropic",
            "model": "sonnet", "mode": "agent", "allowed_tools": [],
            "messages": [{"id": "m1", "role": "user", "content": text, "branch_id": "main"}],
            "branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None, "created_at": "2026-01-01"}},
            "active_branch_id": "main", "tool_group_meta": {}, "active_mcps": [], "dashboard_id": did,
        }

    (sdir / f"{sid1}.json").write_text(json.dumps(sess(sid1, "Agent One", "from one")))
    (sdir / f"{sid2}.json").write_text(json.dumps(sess(sid2, "Agent Two", "from two")))
    (ddir / f"{did}.json").write_text(json.dumps({"id": did, "name": "Board", "layout": {
        "cards": {sid1: {"session_id": sid1}, sid2: {"session_id": sid2}},
        "view_cards": {},
        "browser_cards": {bkey: {"browser_id": bkey, "url": "u", "spawned_by": None}},
        "expanded_session_ids": [sid1],
    }}))

    raw, _ = closure.build_bundle(EntityType.dashboard, did)
    sandbox, manifest, p_w = closure.stage_upload(raw, "board.swarm")
    try:
        p_rt, root_id, p_created, p_u = closure.commit(sandbox, manifest, [])
    finally:
        shutil.rmtree(sandbox, ignore_errors=True)

    L = json.loads((ddir / f"{root_id}.json").read_text())["layout"]
    assert len(L["cards"]) == 2, "both agent cards must survive import"
    assert len(L["browser_cards"]) == 1, "the browser card must survive too"
    total_msgs = 0
    for sid in L["cards"]:
        doc = json.loads((sdir / f"{sid}.json").read_text())
        total_msgs += len(doc.get("messages") or [])
        assert doc["active_mcps"] == [], "import must not grant MCP access"
    assert total_msgs == 2, "each agent's transcript must carry through"

    # The bug behind "the chats didn't even show up": after import the sessions are on disk but not in memory, and the dashboard-open fetch (get_all_sessions) was memory-only, so the cards rendered blank. The fetch must now see the freshly-imported sessions straight off disk.
    found = am.agent_manager.get_all_sessions(dashboard_id=root_id)
    assert len(found) == 2, f"dashboard-open fetch must see imported agent sessions, got {len(found)}"
    assert sum(len(s.messages) for s in found) == 2, "and with their transcripts"


def test_get_all_sessions_does_not_resurrect_deleted_cards(tmp_path, monkeypatch):
    # Deleting a card removes it from the layout but the session keeps its dashboard_id on disk. get_all_sessions must surface only sessions the layout still has a card for, or deleted chats come back on every reopen.
    from backend.apps.agents import agent_manager as am
    import backend.config.paths as paths
    sdir = tmp_path / "sessions"
    ddir = tmp_path / "dashboards"
    sdir.mkdir()
    ddir.mkdir()
    monkeypatch.setattr(am, "SESSIONS_DIR", str(sdir))
    monkeypatch.setattr(paths, "DASHBOARDS_DIR", str(ddir))
    monkeypatch.setattr(am.agent_manager, "sessions", {})

    did = "d1"

    def sess(sid):
        return {
            "id": sid, "name": sid, "status": "completed", "model": "sonnet",
            "mode": "agent", "messages": [], "branches": {}, "active_branch_id": "main",
            "dashboard_id": did,
        }

    (sdir / "kept.json").write_text(json.dumps(sess("kept")))
    (sdir / "deleted.json").write_text(json.dumps(sess("deleted")))  # still tagged, card gone
    # The layout has a card only for "kept" (the user deleted "deleted"'s card).
    (ddir / f"{did}.json").write_text(json.dumps({"id": did, "layout": {"cards": {"kept": {"session_id": "kept"}}}}))

    ids = {s.id for s in am.agent_manager.get_all_sessions(dashboard_id=did)}
    assert "kept" in ids, "a session the layout still has a card for must surface"
    assert "deleted" not in ids, "a session whose card was deleted must NOT resurrect"


def test_dashboard_serialize_rewrites_refs_to_bundle_ids():
    from backend.apps.swarm.entities.dashboards import DashboardExportable
    from backend.apps.swarm.models import EntityType

    class Ctx:
        def bundle_id_for(self, t: EntityType, lid: str):
            return {("session", "S"): "SBID", ("app", "A"): "ABID"}.get((t.value, lid))

    data = {"name": "D", "layout": {
        "cards": {"S": {"session_id": "S", "x": 1}},
        "view_cards": {"A": {"output_id": "A", "x": 2, "parent_session_id": "S"}},
        "browser_cards": {"b1": {"browser_id": "b1", "url": "u", "spawned_by": "S"}},
        "expanded_session_ids": ["S"],
    }}
    L = DashboardExportable("d1", "D", data).serialize(Ctx())["layout"]
    assert L["cards"]["SBID"]["session_id"] == "SBID"
    assert L["view_cards"]["ABID"]["output_id"] == "ABID"
    # the app card's tether to its builder agent is a session id, so it remaps too
    assert L["view_cards"]["ABID"]["parent_session_id"] == "SBID"
    assert L["browser_cards"]["b1"]["spawned_by"] == "SBID"
    assert L["expanded_session_ids"] == ["SBID"]


def test_dashboard_import_remaps_to_fresh_local_ids(monkeypatch):
    from backend.apps.swarm.entities import dashboards as dmod
    from backend.apps.swarm.exportable import RemapTable

    written: dict = {}
    monkeypatch.setattr(dmod, "p_write", lambda did, doc: written.update({did: doc}))
    monkeypatch.setattr(dmod, "p_retag_sessions", lambda ids, did: None)
    remap = RemapTable()
    remap.assign("SBID", "newsess")
    remap.assign("ABID", "newapp")
    payload = {"name": "D", "layout": {
        "cards": {"SBID": {"session_id": "SBID"}},
        "view_cards": {
            "ABID": {"output_id": "ABID", "parent_session_id": "SBID"},
            "ABID2": {"output_id": "ABID2", "parent_session_id": "GONE"},
        },
        "browser_cards": {"b1": {"browser_id": "b1", "spawned_by": "SBID", "dashboard_id": "OLD_DASH"}},
        "expanded_session_ids": ["SBID", "ORPHAN"],
    }}
    remap.assign("ABID2", "newapp2")
    did = dmod.DashboardExportable.import_(payload, {}, remap)
    L = written[did]["layout"]
    assert L["cards"]["newsess"]["session_id"] == "newsess"
    assert L["view_cards"]["newapp"]["parent_session_id"] == "newsess"
    assert L["view_cards"]["newapp2"]["parent_session_id"] is None  # parent not in bundle
    assert list(L["browser_cards"].values())[0]["spawned_by"] == "newsess"
    # The card's home dashboard must be re-stamped to the new id, else the anti-bleed render guard hides it on the import.
    assert list(L["browser_cards"].values())[0]["dashboard_id"] == did
    assert L["expanded_session_ids"] == ["newsess"]  # the dangling ref is dropped


def test_dashboard_remap_invariant_generative(monkeypatch):
    # The hand-written remap tests only check the id-bearing fields I remembered. Generate random dashboards and assert the real invariant on a serialize -> import round-trip: no source-local id and no bundle id survives into the imported layout, and every card id is a freshly-minted local id. This is what catches "someone adds a new layout field holding a session id and forgets to remap it."
    import random

    from backend.apps.swarm.entities import dashboards as dmod
    from backend.apps.swarm.exportable import RemapTable
    from backend.apps.swarm.models import EntityType

    written: dict = {}
    monkeypatch.setattr(dmod, "p_write", lambda did, doc: written.update({did: doc}))
    monkeypatch.setattr(dmod, "p_retag_sessions", lambda ids, did: None)

    rng = random.Random(1234)
    for _ in range(60):
        sess = [f"S{i}" for i in range(rng.randint(0, 5))]
        apps = [f"A{i}" for i in range(rng.randint(0, 4))]
        s_bid = {s: f"sbid{i}" for i, s in enumerate(sess)}
        a_bid = {a: f"abid{i}" for i, a in enumerate(apps)}

        class Ctx:
            def bundle_id_for(self, t, lid):
                if t == EntityType.session:
                    return s_bid.get(lid)
                if t == EntityType.app:
                    return a_bid.get(lid)
                return None

        layout = {
            "cards": {s: {"session_id": s, "x": rng.randint(0, 9)} for s in sess},
            "view_cards": {
                a: {"output_id": a,
                    "parent_session_id": (rng.choice(sess + ["ORPHAN"]) if sess and rng.random() < 0.7 else None)}
                for a in apps
            },
            "browser_cards": {
                f"b{i}": {"browser_id": f"b{i}", "url": "u",
                          "spawned_by": (rng.choice(sess) if sess and rng.random() < 0.7 else None)}
                for i in range(rng.randint(0, 3))
            },
            "expanded_session_ids": (sess + ["ORPHAN"]) if rng.random() < 0.5 else list(sess),
        }
        payload = dmod.DashboardExportable("d-src", "D", {"name": "D", "layout": layout}).serialize(Ctx())

        remap = RemapTable()
        fresh_sess = {s: f"new-{s_bid[s]}" for s in sess}
        fresh_apps = {a: f"new-{a_bid[a]}" for a in apps}
        for s in sess:
            remap.assign(s_bid[s], fresh_sess[s])
        for a in apps:
            remap.assign(a_bid[a], fresh_apps[a])

        did = dmod.DashboardExportable.import_(payload, {}, remap)
        L = written[did]["layout"]

        forbidden = set(sess) | set(apps) | set(s_bid.values()) | set(a_bid.values())
        assert set(L["cards"]) == set(fresh_sess.values())
        assert set(L["view_cards"]) == set(fresh_apps.values())
        for cid, card in L["cards"].items():
            assert cid not in forbidden and card["session_id"] == cid
        for oid, card in L["view_cards"].items():
            assert oid not in forbidden and card["output_id"] == oid
            p = card["parent_session_id"]
            assert p is None or (p in set(fresh_sess.values()) and p not in forbidden)
        assert set(L["expanded_session_ids"]) <= set(fresh_sess.values())
        for card in L["browser_cards"].values():
            assert card["spawned_by"] is None or card["spawned_by"] in set(fresh_sess.values())


def test_checksum_rejects_tampering(skill_store):
    p_make_skill(skill_store, "tmp", "Tmp", "# original")
    raw, _ = closure.build_bundle(EntityType.skill, "tmp")
    # Rebuild the zip with the same manifest (old checksum) but an edited payload.
    src = zipfile.ZipFile(io.BytesIO(raw))
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w") as out:
        for n in src.namelist():
            data = src.read(n)
            if n.endswith("payload.json"):
                d = json.loads(data)
                d["content"] = "TAMPERED"
                data = json.dumps(d, indent=2).encode("utf-8")
            out.writestr(n, data)
    with pytest.raises(BundleError):
        closure.stage_upload(buf.getvalue(), "tmp.swarm")


def test_skill_rollback_removes_it(skill_store):
    from backend.apps.swarm.entities.skills import SkillExportable
    from backend.apps.swarm.exportable import RemapTable
    sid = SkillExportable.import_({"slug": "rbk", "name": "Rbk", "content": "x"}, {}, RemapTable())
    assert (skill_store / sid / "SKILL.md").exists()
    SkillExportable.rollback(sid)
    assert not (skill_store / sid).exists()
    assert sid not in store.load_index()


def test_commit_rolls_back_created_on_failure(skill_store, tmp_path, monkeypatch):
    # A bundle of [skill, workflow]: the skill imports first and lands, then the workflow import fails, so the skill must be rolled back (all-or-nothing, no half-write). The failure used to come for free (no workflow store on this branch); now the store exists, so force it deterministically by making the workflow import raise.
    from backend.apps.swarm.models import BundlePreview, EntityRef, Manifest
    from backend.apps.swarm.entities.workflows import WorkflowExportable

    def p_boom(*a, **k):
        raise BundleError("simulated workflow import failure")
    monkeypatch.setattr(WorkflowExportable, "import_", p_boom)

    sb = tmp_path / "sb"
    skill_ref = EntityRef(type=EntityType.skill, bundle_id="s1", name="S", path="entities/s1")
    wf_ref = EntityRef(type=EntityType.workflow, bundle_id="w1", name="W", path="entities/w1")
    for ref, payload in ((skill_ref, {"slug": "rollme", "name": "Rollme", "content": "hi"}), (wf_ref, {"title": "W"})):
        d = sb / "entities" / ref.bundle_id
        d.mkdir(parents=True)
        (d / "payload.json").write_text(json.dumps(payload), encoding="utf-8")
    manifest = Manifest(
        bundle_id="b", root=skill_ref, entities=[skill_ref, wf_ref],
        preview=BundlePreview(root_type=EntityType.skill, root_name="S"),
    )
    with pytest.raises(BundleError):
        closure.commit(str(sb), manifest, [])
    assert "rollme" not in store.load_index()
    assert not (skill_store / "rollme").exists()  # the imported folder was rolled back


def test_manifest_duplicate_ids_rejected():
    # Two entities sharing a bundle_id silently collapse in the topo/summary dicts, dropping one; reject up front. (The manifest is outside the checksum.)
    from backend.apps.swarm.closure import validate_manifest
    from backend.apps.swarm.models import BundlePreview, EntityRef, Manifest
    ref = EntityRef(type=EntityType.skill, bundle_id="dup", name="A", path="entities/dup")
    m = Manifest(bundle_id="b", root=ref, entities=[ref, ref],
                 preview=BundlePreview(root_type=EntityType.skill, root_name="A"))
    with pytest.raises(BundleError):
        validate_manifest(m)


def test_manifest_root_not_in_entities_rejected():
    from backend.apps.swarm.closure import validate_manifest
    from backend.apps.swarm.models import BundlePreview, EntityRef, Manifest
    root = EntityRef(type=EntityType.skill, bundle_id="root", name="A", path="entities/root")
    other = EntityRef(type=EntityType.skill, bundle_id="other", name="B", path="entities/other")
    m = Manifest(bundle_id="b", root=root, entities=[other],
                 preview=BundlePreview(root_type=EntityType.skill, root_name="A"))
    with pytest.raises(BundleError):
        validate_manifest(m)


def test_manifest_edge_to_unknown_entity_rejected():
    from backend.apps.swarm.closure import validate_manifest
    from backend.apps.swarm.models import BundlePreview, DependencyEdge, EntityRef, Manifest
    ref = EntityRef(type=EntityType.dashboard, bundle_id="d", name="D", path="entities/d")
    m = Manifest(bundle_id="b", root=ref, entities=[ref],
                 edges=[DependencyEdge(**{"from": "d", "to": "ghost"})],
                 preview=BundlePreview(root_type=EntityType.dashboard, root_name="D"))
    with pytest.raises(BundleError):
        validate_manifest(m)


def p_zip_with(name, data=b"x"):
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w") as zf:
        zf.writestr(name, data)
    return buf.getvalue()


def test_zip_slip_rejected():
    with pytest.raises(BundleError):
        unpack(p_zip_with("../escape.txt"))


def test_absolute_path_rejected():
    with pytest.raises(BundleError):
        unpack(p_zip_with("/etc/evil"))


def test_symlink_entry_rejected():
    # A symlink entry could point outside the sandbox once followed; unpack must refuse it before writing anything.
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w") as zf:
        zi = zipfile.ZipInfo("link")
        zi.external_attr = 0o120777 << 16
        zf.writestr(zi, "/etc/passwd")
    with pytest.raises(BundleError):
        unpack(buf.getvalue())


def test_too_many_entries_rejected():
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w") as zf:
        for i in range(5001):
            zf.writestr(f"f{i}.txt", b"x")
    with pytest.raises(BundleError):
        unpack(buf.getvalue())


def test_newer_format_version_rejected(skill_store):
    # A bundle from a future OpenSwarm should fail clearly, not half-import.
    buf = io.BytesIO()
    manifest = {
        "format_version": 999,
        "bundle_id": "b",
        "root": {"type": "skill", "bundle_id": "x", "name": "n", "path": "entities/x"},
        "entities": [{"type": "skill", "bundle_id": "x", "name": "n", "path": "entities/x"}],
        "preview": {"root_type": "skill", "root_name": "n"},
    }
    with zipfile.ZipFile(buf, "w") as zf:
        zf.writestr("manifest.json", json.dumps(manifest))
        zf.writestr("entities/x/payload.json", json.dumps({"slug": "n", "name": "n", "content": "c"}))
    with pytest.raises(BundleError):
        closure.stage_upload(buf.getvalue(), "x.swarm")
