"""Real edit -> executor -> SQLite -> history projection, without model calls."""
import copy
import contextlib
import json
from pathlib import Path
from types import SimpleNamespace

import pytest

from tests.agent.test_tool_call_incremental_persistence import (
    _attach_real_session_db,
    _make_agent,
    _mock_tool_call,
)


@pytest.mark.parametrize("executor_mode", ["sequential", "concurrent"])
@pytest.mark.parametrize("edit", ["create", "replace", "patch", "noop", "failed"])
def test_edit_preview_is_durable_before_emission_and_display_only(
    tmp_path, monkeypatch, executor_mode, edit,
):
    from agent.context_compressor import ContextCompressor
    from agent import secret_scope
    from agent.turn_context import build_api_messages
    from hermes_state import SessionDB
    from tools.environments.local import LocalEnvironment
    from tools.file_operations import ShellFileOperations
    from tools.terminal_tool import register_task_env_overrides, clear_task_env_overrides
    import tools.file_tools as file_tools
    import model_tools
    import tui_gateway.server as progress

    assert Path(model_tools.__file__).resolve().parent == Path(__file__).resolve().parents[2]
    monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
    monkeypatch.setattr("agent.title_generator.maybe_auto_title", lambda *a, **kw: None)
    # Deliberately different from the execution cwd: relative previews must use
    # the same task target as the real file tool, not today's process directory.
    process_cwd = tmp_path / "process"
    process_cwd.mkdir()
    monkeypatch.chdir(process_cwd)
    emitted = []
    at_emission = []
    monkeypatch.setattr(progress, "_emit_tool_lifecycle", lambda *a: emitted.append(a))
    homes = {tag: tmp_path / tag for tag in ("alpha", "beta")}
    for home in homes.values():
        home.mkdir()
        (home / "config.yaml").write_text("terminal:\n  backend: local\n", encoding="utf-8")

    # Same tool identity reused in separate homes: no process-global diff cache.
    for visit, tag in enumerate(("alpha", "beta", "alpha")):
        home = homes[tag]
        scope = contextlib.ExitStack()
        scope.enter_context(progress._session_profile_runtime_scope({"profile_home": str(home)}, hydrate_secrets=False))
        work = home / f"work-{visit}"
        work.mkdir()
        env = LocalEnvironment(cwd=str(work), timeout=15)
        ops = ShellFileOperations(env, cwd=str(work))
        monkeypatch.setattr(file_tools, "_get_file_ops", lambda task_id="default": ops)
        path = work / "publish.py"
        before, after = "print('before')\n", f"print('{tag}-{visit}')\n"
        tool_name = "patch" if edit == "patch" else "write_file"
        if edit != "create":
            path.write_text(before, encoding="utf-8")
        args = {"path": "publish.py", "content": before if edit == "noop" else after}
        if edit == "patch":
            args = {"path": "publish.py", "old_string": before.strip(), "new_string": after.strip()}
        name = f"{executor_mode}-{edit}-{tag}-{visit}"
        call_id = "edit-call"
        agent = _make_agent()
        agent.valid_tool_names = {tool_name}
        db_path = home / "state.db"
        db = _attach_real_session_db(agent, db_path, name)
        register_task_env_overrides(name, {"cwd": str(work), "env_type": "local"})
        monkeypatch.setitem(progress._sessions, name, {"agent": agent, "session_key": name})
        # Wire the real gateway callbacks, including pre-commit preparation.
        for key, callback in progress._agent_cbs(name).items():
            setattr(agent, key, callback)
        if edit in {"replace", "noop"}:
            file_tools.read_file_tool("publish.py", task_id=name)
        messages = [
            {"role": "user", "content": "Update the file"},
            {"role": "assistant", "content": "", "tool_calls": [{
                "id": call_id, "type": "function", "function": {
                    "name": tool_name, "arguments": json.dumps(args),
                },
            }]},
        ]
        agent._flush_messages_to_session_db(messages)
        emitted.clear()
        at_emission.clear()

        def emit(kind, sid, name, args, payload):
            emitted.append((kind, copy.deepcopy(payload)))
            if kind == "tool.complete":
                with SessionDB(db_path=db_path) as cold:
                    at_emission.extend(cold.get_messages(sid))

        monkeypatch.setattr(progress, "_emit_tool_lifecycle", emit)
        try:
            call = _mock_tool_call(tool_name, json.dumps(args), call_id)
            getattr(agent, f"_execute_tool_calls_{executor_mode}")(
                SimpleNamespace(tool_calls=[call]), messages, name,
            )
            completion = next(payload for kind, payload in emitted if kind == "tool.complete")
            row = next(row for row in at_emission if row["role"] == "tool")
            raw = json.loads(row["content"])
            assert raw == completion["result"]
            assert "inline_diff" not in raw
            changed = edit not in {"noop", "failed"}
            assert path.read_text(encoding="utf-8") == (after if changed else before)
            assert bool(raw.get("error")) == (edit == "failed")
            metadata = (row.get("display_metadata") or {}).get("tool_result_metadata") or {}
            # This assertion was red on base: completion saw no persisted sidecar.
            assert bool(metadata.get("inline_diff")) == changed
            assert metadata.get("inline_diff") == completion.get("inline_diff")
            if changed:
                assert after.strip() in metadata["inline_diff"]
            with SessionDB(db_path=db_path) as cold:
                conversation = cold.get_messages_as_conversation(name)
            projected = progress._history_to_messages(conversation)
            projected_tool = next(row for row in projected if row["role"] == "tool")
            assert projected_tool["tool_call_id"] == call_id
            assert projected_tool["content"] == row["content"]
            assert projected_tool.get("display_metadata") == row.get("display_metadata")
            assert projected_tool["timestamp"] == row["timestamp"]

            # Preparation never modifies provider bytes or the cached prefix.
            agent._current_turn_timestamp = 1.0
            frozen = copy.deepcopy(messages)
            def wire(history):
                return build_api_messages(
                    agent, history, current_turn_user_idx=0, ext_prefetch_cache="",
                    plugin_user_context="", moa_config=None, active_system_prompt="fixed prefix",
                )
            without_display = [{k: v for k, v in m.items() if k != "display_metadata"} for m in messages]
            assert wire(messages) == wire(without_display)
            before_next_turn = wire(messages)
            assert wire(messages + [{"role": "user", "content": "Continue"}])[0][:-1] == before_next_turn[0]
            assert messages == frozen

            # Cold display does not reread the now-changed target. Exercise real
            # compaction demotion + DB archival, preserving the original tool row.
            path.write_text("print('unrelated later edit')\n", encoding="utf-8")
            compacted = copy.deepcopy(conversation)
            tool_index = next(i for i, m in enumerate(compacted) if m["role"] == "tool")
            assert ContextCompressor._demote_tool_result_at(
                compacted, tool_index, {call_id: (tool_name, json.dumps(args))}, 0,
            )
            assert compacted[tool_index].get("display_metadata") == row.get("display_metadata")
            db.archive_and_compact(name, compacted)
            with SessionDB(db_path=db_path) as cold:
                archived = cold.get_messages(name, include_compacted=True)
                active = cold.get_messages_as_conversation(name)
            assert next(m for m in archived if m["id"] == row["id"])["content"] == row["content"]
            assert next(m for m in active if m["role"] == "tool").get("display_metadata") == row.get("display_metadata")
        finally:
            clear_task_env_overrides(name)
            db.close()
            env.cleanup()
            scope.close()


def test_orphaned_preview_never_attaches_to_a_reused_call_id(monkeypatch):
    import tui_gateway.server as progress

    emitted = []
    monkeypatch.setattr(progress, "_emit_tool_lifecycle", lambda kind, sid, name, args, payload: emitted.append(payload))
    session = {"tool_result_metadata": {"call_0": {"inline_diff": "STALE"}}}
    monkeypatch.setitem(progress._sessions, "reuse", session)
    # The prepared call's flush failed, so its completion never fired; a later call reuses the id.
    progress._on_tool_start("reuse", "call_0", "todo", {})
    progress._on_tool_complete("reuse", "call_0", "todo", {}, '{"todos": []}')
    assert emitted and not any("inline_diff" in payload for payload in emitted)
    assert session["tool_result_metadata"] == {}

    # A completion dropped as stale still consumes its prepared entry.
    session["tool_result_metadata"]["call_1"] = {}
    monkeypatch.setattr(progress, "_connector_lifecycle_is_stale", lambda *a: True)
    progress._on_tool_complete("reuse", "call_1", "todo", {}, "{}")
    assert session["tool_result_metadata"] == {}
