import json
import os
import time
import pytest
from unittest.mock import patch, MagicMock

from brynq.agent_loop import BrynqAgent, _append_jsonl, _read_jsonl, _read_cursor


@pytest.fixture
def agent(broker_dirs):
    """Create a BrynqAgent wired to temp broker dirs."""
    with patch("brynq.agent_loop.BROKER_DIR", broker_dirs["channels"].parent), \
         patch("brynq.agent_loop.CHANNELS_DIR", broker_dirs["channels"]), \
         patch("brynq.agent_loop.SESSIONS_DIR", broker_dirs["sessions"]), \
         patch("brynq.agent_loop.CURSORS_DIR", broker_dirs["cursors"]):
        yield BrynqAgent(name="testbot", backend="ollama", model="llama3", channel="test")


# ── Init ──────────────────────────────────────────────────────────────────


class TestAgentInit:
    def test_session_id_format(self, agent):
        assert agent.session_id == "agent-testbot"

    def test_default_backend(self, agent):
        assert agent.backend == "ollama"

    def test_default_model(self, agent):
        assert agent.model == "llama3"

    def test_channel(self, agent):
        assert agent.channel == "test"

    def test_custom_system_prompt(self, broker_dirs):
        with patch("brynq.agent_loop.BROKER_DIR", broker_dirs["channels"].parent), \
             patch("brynq.agent_loop.CHANNELS_DIR", broker_dirs["channels"]), \
             patch("brynq.agent_loop.SESSIONS_DIR", broker_dirs["sessions"]), \
             patch("brynq.agent_loop.CURSORS_DIR", broker_dirs["cursors"]):
            a = BrynqAgent(name="x", system_prompt="Be helpful")
            assert a.system_prompt == "Be helpful"


# ── Channel I/O ───────────────────────────────────────────────────────────


class TestPostToChannel:
    def test_appends_to_channel_file(self, agent, broker_dirs):
        agent._post_to_channel("hello")
        path = broker_dirs["channels"] / "test.jsonl"
        assert path.exists()
        lines = path.read_text().strip().split("\n")
        assert len(lines) == 1
        data = json.loads(lines[0])
        assert data["message"] == "hello"

    def test_message_contains_session_id(self, agent, broker_dirs):
        agent._post_to_channel("hello")
        path = broker_dirs["channels"] / "test.jsonl"
        data = json.loads(path.read_text().strip().split("\n")[-1])
        assert data["session_id"] == agent.session_id

    def test_message_contains_timestamp(self, agent, broker_dirs):
        agent._post_to_channel("hello")
        path = broker_dirs["channels"] / "test.jsonl"
        data = json.loads(path.read_text().strip().split("\n")[-1])
        assert "timestamp" in data

    def test_message_type_default_is_message(self, agent, broker_dirs):
        agent._post_to_channel("hello")
        path = broker_dirs["channels"] / "test.jsonl"
        data = json.loads(path.read_text().strip().split("\n")[-1])
        assert data["msg_type"] == "message"

    def test_custom_message_type(self, agent, broker_dirs):
        agent._post_to_channel("alert!", msg_type="alert")
        path = broker_dirs["channels"] / "test.jsonl"
        data = json.loads(path.read_text().strip().split("\n")[-1])
        assert data["msg_type"] == "alert"


# ── Tick ──────────────────────────────────────────────────────────────────


class TestTick:
    def test_returns_false_when_no_messages(self, agent):
        assert agent._tick() is False

    def test_skips_own_messages(self, agent, broker_dirs):
        path = broker_dirs["channels"] / "test.jsonl"
        _append_jsonl(path, {
            "id": "aaa",
            "timestamp": "2026-01-01T00:00:00+00:00",
            "session_id": agent.session_id,
            "session_name": "testbot",
            "platform": "ollama",
            "channel": "test",
            "msg_type": "message",
            "message": "my own message",
        })
        assert agent._tick() is False

    def test_skips_status_messages(self, agent, broker_dirs):
        path = broker_dirs["channels"] / "test.jsonl"
        _append_jsonl(path, {
            "id": "bbb",
            "timestamp": "2026-01-01T00:00:00+00:00",
            "session_id": "other-agent",
            "session_name": "other",
            "platform": "cli",
            "channel": "test",
            "msg_type": "status",
            "message": "joined the broker",
        })
        assert agent._tick() is False

    @patch("brynq.agent_loop._call_ollama", return_value="mock reply")
    def test_calls_backend_and_posts_response(self, mock_ollama, agent, broker_dirs):
        path = broker_dirs["channels"] / "test.jsonl"
        _append_jsonl(path, {
            "id": "ccc",
            "timestamp": "2026-01-01T00:00:00+00:00",
            "session_id": "human-ceo",
            "session_name": "CEO",
            "platform": "cli",
            "channel": "test",
            "msg_type": "message",
            "message": "hello agent",
        })
        result = agent._tick()
        assert result is True
        mock_ollama.assert_called_once()

        # Should have posted a response
        entries = _read_jsonl(path)
        assert len(entries) == 2
        assert entries[1]["message"] == "mock reply"
        assert entries[1]["session_id"] == agent.session_id

    @patch("brynq.agent_loop._call_ollama", side_effect=RuntimeError("llm down"))
    def test_handles_backend_failure_gracefully(self, mock_ollama, agent, broker_dirs):
        path = broker_dirs["channels"] / "test.jsonl"
        _append_jsonl(path, {
            "id": "ddd",
            "timestamp": "2026-01-01T00:00:00+00:00",
            "session_id": "other",
            "session_name": "other",
            "platform": "cli",
            "channel": "test",
            "msg_type": "message",
            "message": "hello",
        })
        result = agent._tick()
        assert result is True  # still returns True — it tried to process

        # Should have posted an error message
        entries = _read_jsonl(path)
        assert len(entries) == 2
        assert "[ERROR]" in entries[1]["message"]

    @patch("brynq.agent_loop._call_ollama", return_value="reply")
    def test_advances_cursor(self, mock_ollama, agent, broker_dirs):
        path = broker_dirs["channels"] / "test.jsonl"
        _append_jsonl(path, {
            "id": "eee",
            "timestamp": "2026-01-01T00:00:00+00:00",
            "session_id": "other",
            "session_name": "other",
            "platform": "cli",
            "channel": "test",
            "msg_type": "message",
            "message": "hello",
        })
        agent._tick()
        cursor = _read_cursor(agent.session_id, "test")
        assert cursor >= 1

    @patch("brynq.agent_loop._call_ollama", return_value="first reply")
    def test_does_not_reprocess_old_messages(self, mock_ollama, agent, broker_dirs):
        path = broker_dirs["channels"] / "test.jsonl"
        _append_jsonl(path, {
            "id": "fff",
            "timestamp": "2026-01-01T00:00:00+00:00",
            "session_id": "other",
            "session_name": "other",
            "platform": "cli",
            "channel": "test",
            "msg_type": "message",
            "message": "hello",
        })
        agent._tick()
        mock_ollama.reset_mock()

        # Second tick should not re-call the backend
        agent._tick()
        mock_ollama.assert_not_called()


# ── Heartbeat ─────────────────────────────────────────────────────────────


class TestHeartbeat:
    def test_writes_session_file(self, agent, broker_dirs):
        agent._write_heartbeat()
        path = broker_dirs["sessions"] / f"{agent.session_id}.json"
        assert path.exists()

    def test_session_file_contains_pid(self, agent, broker_dirs):
        agent._write_heartbeat()
        path = broker_dirs["sessions"] / f"{agent.session_id}.json"
        data = json.loads(path.read_text())
        assert data["pid"] == os.getpid()

    def test_session_file_contains_model_info(self, agent, broker_dirs):
        agent._write_heartbeat()
        path = broker_dirs["sessions"] / f"{agent.session_id}.json"
        data = json.loads(path.read_text())
        assert data["model"] == "llama3"
        assert data["platform"] == "ollama"
        assert data["channel"] == "test"

    def test_heartbeat_timestamp_updates(self, agent, broker_dirs):
        agent._write_heartbeat()
        path = broker_dirs["sessions"] / f"{agent.session_id}.json"
        data1 = json.loads(path.read_text())
        time.sleep(0.01)
        agent._write_heartbeat()
        data2 = json.loads(path.read_text())
        assert data1["heartbeat"] != data2["heartbeat"]

    def test_maybe_heartbeat_respects_interval(self, agent, broker_dirs):
        agent._write_heartbeat()
        path = broker_dirs["sessions"] / f"{agent.session_id}.json"
        data1 = json.loads(path.read_text())

        # Should NOT write again immediately
        agent._maybe_heartbeat()
        data2 = json.loads(path.read_text())
        assert data1["heartbeat"] == data2["heartbeat"]


# ── Backend dispatch ──────────────────────────────────────────────────────


class TestCallBackend:
    @patch("brynq.agent_loop._call_ollama", return_value="ollama reply")
    def test_routes_to_ollama(self, mock_fn, agent):
        result = agent._call_backend("test prompt")
        assert result == "ollama reply"
        mock_fn.assert_called_once_with("llama3", "test prompt", "")

    def test_routes_to_claude(self, broker_dirs):
        with patch("brynq.agent_loop.CHANNELS_DIR", broker_dirs["channels"]), \
             patch("brynq.agent_loop.SESSIONS_DIR", broker_dirs["sessions"]), \
             patch("brynq.agent_loop.CURSORS_DIR", broker_dirs["cursors"]), \
             patch("brynq.agent_loop._call_claude_subprocess", return_value="claude reply") as mock_fn:
            a = BrynqAgent(name="c", backend="claude", model="claude-3-haiku-20240307")
            result = a._call_backend("test prompt")
            assert result == "claude reply"
            mock_fn.assert_called_once()

    def test_routes_to_gemini(self, broker_dirs):
        with patch("brynq.agent_loop.CHANNELS_DIR", broker_dirs["channels"]), \
             patch("brynq.agent_loop.SESSIONS_DIR", broker_dirs["sessions"]), \
             patch("brynq.agent_loop.CURSORS_DIR", broker_dirs["cursors"]), \
             patch("brynq.agent_loop._call_gemini_subprocess", return_value="gemini reply") as mock_fn:
            a = BrynqAgent(name="g", backend="gemini", model="gemini-3.1-pro-preview")
            result = a._call_backend("test prompt")
            assert result == "gemini reply"
            mock_fn.assert_called_once()

    def test_unknown_backend_raises(self, agent):
        agent.backend = "unknown"
        with pytest.raises(ValueError, match="Unknown backend"):
            agent._call_backend("test")


# ── Run loop ──────────────────────────────────────────────────────────────


class TestRun:
    @patch("brynq.agent_loop.BrynqAgent._tick", return_value=False)
    @patch("brynq.agent_loop.BrynqAgent._write_heartbeat")
    @patch("time.sleep")
    def test_run_stops_on_keyboard_interrupt(self, mock_sleep, mock_hb, mock_tick, agent):
        mock_tick.side_effect = KeyboardInterrupt
        agent.run()  # Should not raise
        mock_hb.assert_called_once()

    def test_stop_sets_running_false(self, agent):
        assert agent._running is True
        agent.stop()
        assert agent._running is False


# ── CLI argument parsing ──────────────────────────────────────────────────


class TestParseArgs:
    def test_all_required_args(self):
        from brynq.agent_loop import _parse_args
        args = _parse_args(["--name", "w1", "--backend", "ollama", "--model", "llama3", "--channel", "tasks"])
        assert args.name == "w1"
        assert args.backend == "ollama"
        assert args.model == "llama3"
        assert args.channel == "tasks"
        assert args.system_prompt == ""

    def test_system_prompt_arg(self):
        from brynq.agent_loop import _parse_args
        args = _parse_args(["--name", "w1", "--backend", "claude", "--model", "opus", "--channel", "c", "--system-prompt", "Be kind"])
        assert args.system_prompt == "Be kind"

    def test_invalid_backend_rejected(self):
        from brynq.agent_loop import _parse_args
        with pytest.raises(SystemExit):
            _parse_args(["--name", "w1", "--backend", "gpt4", "--model", "m", "--channel", "c"])

    def test_missing_required_arg_rejected(self):
        from brynq.agent_loop import _parse_args
        with pytest.raises(SystemExit):
            _parse_args(["--name", "w1"])
