from __future__ import annotations

import base64
import json
import os
import subprocess
import sys
import textwrap

from agent.system_prompt import build_system_prompt, invalidate_system_prompt
from hermes_cli import plugins
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
from run_agent import AIAgent


def _real_agent(*, session_id: str = "plugin-section-test") -> AIAgent:
    return AIAgent(
        api_key="test-key",
        base_url="https://openrouter.ai/api/v1",
        model="test/model",
        provider="openrouter",
        platform="cli",
        quiet_mode=True,
        skip_context_files=True,
        skip_memory=True,
        session_id=session_id,
    )


def _install_test_section(manager: PluginManager, content) -> None:
    manager._discovered = True
    ctx = PluginContext(
        PluginManifest(name="example-plugin", key="example-plugin", source="user"),
        manager,
    )
    ctx.register_system_prompt_section(
        "example.rules",
        content,
        position="after_memory",
        max_chars=1000,
    )


def test_real_aiagent_freezes_section_within_life_and_rerenders_on_invalidate(monkeypatch):
    # Pin the workspace snapshot: build_coding_workspace_block shells out to
    # live `git status`/`git log` on every build, and a git call failing or
    # timing out under test-suite contention makes the two builds differ in
    # the Branch/Recent-commits lines — a flake unrelated to what this test
    # asserts (plugin sections). Byte-stability of the REAL workspace block
    # is coding_context's contract, covered by its own tests.
    monkeypatch.setattr(
        "agent.coding_context.build_coding_workspace_block",
        lambda cwd=None: "Workspace (snapshot at session start):\n- Root: /pinned",
    )
    calls = []

    def section(session_info):
        calls.append(dict(session_info))
        return f"rules render {len(calls)}"

    manager = PluginManager()
    _install_test_section(manager, section)
    monkeypatch.setattr(plugins, "_plugin_manager", manager)
    agent = _real_agent()

    first = build_system_prompt(agent)
    # Within a prompt's life the frozen section is reused: a second build
    # WITHOUT invalidation must not re-run plugin code.
    again = build_system_prompt(agent)
    assert again == first
    assert len(calls) == 1

    # invalidate_system_prompt is the compaction/rebuild boundary (#98426):
    # plugin sections re-render there like every other prompt block, so a
    # long-lived session's plugin context converges instead of freezing at
    # its birth bytes.
    invalidate_system_prompt(agent)
    rebuilt = build_system_prompt(agent)

    assert len(calls) == 2
    assert "rules render 2" in rebuilt
    assert "rules render 1" not in rebuilt
    assert calls[0]["session_id"] == agent.session_id
    assert calls[1]["session_id"] == agent.session_id
    assert "## Plugin Context: example.rules" in rebuilt
    assert rebuilt.index("## Plugin Context: example.rules") < rebuilt.index("Conversation started:")
    assert "example.rules" not in agent._cached_system_prompt_static


def test_fresh_process_resume_restores_identical_full_prompt_without_callback(tmp_path):
    """The existing persisted full prompt is the only resume state required."""
    db_path = tmp_path / "state.db"
    calls_path = tmp_path / "calls.txt"
    child = textwrap.dedent(
        """
        import base64
        import json
        import os
        from pathlib import Path

        from agent.conversation_loop import _restore_or_build_system_prompt
        from agent.system_prompt import build_system_prompt, invalidate_system_prompt
        from hermes_cli import plugins
        from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
        from hermes_state import SessionDB
        from run_agent import AIAgent

        db = SessionDB(db_path=Path(os.environ["TEST_DB"]))
        session_id = "resume-plugin-section"
        db.ensure_session(session_id, source="cli", model="test/model")
        agent = AIAgent(
            api_key="test-key",
            base_url="https://openrouter.ai/api/v1",
            model="test/model",
            provider="openrouter",
            platform="cli",
            quiet_mode=True,
            skip_context_files=True,
            skip_memory=True,
            session_id=session_id,
            session_db=db,
        )

        manager = PluginManager()
        manager._discovered = True
        ctx = PluginContext(
            PluginManifest(name="example-plugin", key="example-plugin", source="user"),
            manager,
        )
        calls_path = Path(os.environ["TEST_CALLS"])
        def render(_session_info):
            count = int(calls_path.read_text() or "0") if calls_path.exists() else 0
            calls_path.write_text(str(count + 1))
            return "original bytes" if os.environ["TEST_PHASE"] == "first" else "CHANGED"
        ctx.register_system_prompt_section("example.rules", render, position="after_memory")
        plugins._plugin_manager = manager

        history = [] if os.environ["TEST_PHASE"] == "first" else [
            {"role": "user", "content": "already persisted"}
        ]
        _restore_or_build_system_prompt(agent, None, history)
        restored = agent._cached_system_prompt
        calls_after_restore = int(calls_path.read_text())
        rebuilt_equal = None
        rebuilt_has_changed = None
        calls_after_rebuild = None
        if os.environ["TEST_PHASE"] != "first":
            # invalidate_system_prompt is the compaction boundary (#98426):
            # plugin sections re-render there, so the rebuilt prompt picks
            # up the plugin's CURRENT output — it is EXPECTED to differ
            # from the restored bytes when the plugin's render changed.
            invalidate_system_prompt(agent)
            rebuilt = build_system_prompt(agent)
            rebuilt_equal = rebuilt == restored
            rebuilt_has_changed = "CHANGED" in rebuilt
            calls_after_rebuild = int(calls_path.read_text())
        print(json.dumps({
            "prompt_b64": base64.b64encode(
                restored.encode("utf-8")
            ).decode("ascii"),
            "calls": calls_after_restore,
            "rebuilt_equal": rebuilt_equal,
            "rebuilt_has_changed": rebuilt_has_changed,
            "calls_after_rebuild": calls_after_rebuild,
        }))
        db.close()
        """
    )

    outputs = []
    for phase in ("first", "resume"):
        env = os.environ.copy()
        env.update(
            HERMES_HOME=str(tmp_path / "hermes-home"),
            TEST_DB=str(db_path),
            TEST_CALLS=str(calls_path),
            TEST_PHASE=phase,
        )
        proc = subprocess.run(
            [sys.executable, "-c", child],
            cwd=os.getcwd(),
            env=env,
            text=True,
            capture_output=True,
            timeout=90,
            check=True,
        )
        outputs.append(json.loads(proc.stdout.strip().splitlines()[-1]))

    first_prompt = base64.b64decode(outputs[0]["prompt_b64"])
    resumed_prompt = base64.b64decode(outputs[1]["prompt_b64"])
    if first_prompt != resumed_prompt:
        diff_at = next(
            i for i, (left, right) in enumerate(zip(first_prompt, resumed_prompt))
            if left != right
        )
        raise AssertionError(
            f"prompt bytes differ at {diff_at}: "
            f"first={first_prompt[diff_at - 100:diff_at + 100]!r} "
            f"resumed={resumed_prompt[diff_at - 100:diff_at + 100]!r}"
        )
    assert b"original bytes" in first_prompt
    # RESTORE stays frozen: the resumed prompt is byte-identical, plugin
    # render never ran (calls unchanged). The REBUILD boundary re-renders:
    # the rebuilt prompt carries the plugin's current output and the
    # render ran exactly once more.
    assert b"CHANGED" not in resumed_prompt
    assert outputs[0]["calls"] == outputs[1]["calls"] == 1
    assert outputs[1]["rebuilt_equal"] is False
    assert outputs[1]["rebuilt_has_changed"] is True
    assert outputs[1]["calls_after_rebuild"] == 2
