import json
from pathlib import Path
import sys
from typing import Any, Dict, List, Optional, Tuple

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))

from omnilink import (
    AgentFeedback,
    AgentQuestion,
    PendingQuestion,
    OmniLinkEngine,
    OmniLinkMQTTBridge,
    OmniLinkRemoteCommandBridge,
    TypeRegistry,
    load_patterns_from_file,
)


def test_type_registry_default_conversion():
    registry = TypeRegistry()
    regex, converter = registry.get("int")
    assert regex == r"-?\d+"
    assert converter("42") == 42


def test_type_registry_override_and_duplicate():
    registry = TypeRegistry()
    registry.register("custom", r"[A-Z]+", lambda s: s.lower())
    registry.register("CUSTOM", r"[A-Z]+", lambda s: s[::-1])  # override case-insensitive
    _, converter = registry.get("custom")
    assert converter("ABC") == "CBA"


def test_load_patterns_from_file_handles_comments_quotes_and_bom(tmp_path: Path):
    content = """\ufeff# Comment line\n"greet_world"\n'say [text:any]'\n\ncommand_plain\n"""
    pattern_file = tmp_path / "patterns.txt"
    pattern_file.write_text(content, encoding="utf-8")

    patterns = load_patterns_from_file(pattern_file)
    assert patterns == [
        "greet_world",
        "say [text:any]",
        "command_plain",
    ]


def test_load_patterns_from_relative_path(tmp_path: Path, monkeypatch):
    pattern_file = tmp_path / "nested" / "patterns.txt"
    pattern_file.parent.mkdir(parents=True)
    pattern_file.write_text("command_one\n", encoding="utf-8")

    def fake_getsourcefile(frame):
        return str(tmp_path / "caller.py")

    monkeypatch.setattr("omnilink.getsourcefile", fake_getsourcefile)
    patterns = load_patterns_from_file(Path("nested/patterns.txt"))

    assert patterns == ["command_one"]


def test_remote_bridge_skips_none_command():
    class DummyEngine:
        def __init__(self):
            self.handled = False

        def handle(self, command, meta=None):
            self.handled = True
            return {"handled": True}

    class DummyClient:
        def __init__(self):
            self.updated = False

        def fetch_last_command(self):
            return {"last_command": " None ", "updated_at": "2024-01-01T00:00:00Z"}

        def update_last_response(self, response_payload, last_command=None):
            self.updated = True

    engine = DummyEngine()
    client = DummyClient()
    bridge = OmniLinkRemoteCommandBridge(engine, client=client)
    bridge._last_signature = ("prev", "sig")

    result = bridge.process_once()

    assert result is None
    assert not engine.handled
    assert not client.updated
    assert bridge._last_signature == ("prev", "sig")


def test_remote_bridge_processes_agent_replies_without_rerunning():
    class RemoteEngineStub:
        def __init__(self):
            self.handled: List[Tuple[str, Dict[str, Any]]] = []
            self.replies: List[Dict[str, Any]] = []

        def handle(self, command, meta=None, messenger=None):
            self.handled.append((command, meta or {}))
            return {"ok": True}

        def receive_agent_reply(self, payload: Dict[str, Any]) -> bool:
            self.replies.append(payload)
            return True

    replies = [{"correlationId": "abc123", "answer": "42"}]
    records = [
        {
            "last_command": "build",
            "updated_at": "2024-01-01T00:00:00Z",
            "user_key": "user",
            "last_response": json.dumps({"status": "ok"}),
        },
        {
            "last_command": "build",
            "updated_at": "2024-01-01T00:00:05Z",
            "user_key": "user",
            "last_response": json.dumps({"agentReplies": replies}),
        },
    ]

    class DummyClient:
        def __init__(self, items: List[Dict[str, Any]]):
            self.items = list(items)
            self.updated_payloads: List[str] = []

        def fetch_last_command(self):
            if self.items:
                return self.items.pop(0)
            return None

        def update_last_response(self, response_payload, last_command=None):
            self.updated_payloads.append(response_payload)

    engine = RemoteEngineStub()
    client = DummyClient(records)
    bridge = OmniLinkRemoteCommandBridge(engine, client=client, log=False)

    first = bridge.process_once()
    assert first and first.get("ok") is True
    assert engine.handled and engine.handled[0][0] == "build"
    assert len(client.updated_payloads) == 1

    second = bridge.process_once()
    assert second == {"agent_replies": 1}
    assert len(engine.handled) == 1  # no rerun
    assert engine.replies and engine.replies[0]["answer"] == "42"
    assert len(client.updated_payloads) == 1  # no extra update
    assert bridge._last_signature == ("build", "2024-01-01T00:00:05Z")


def test_remote_bridge_question_flow_uses_default_messenger():
    engine = OmniLinkEngine(["confirm_[item:any]"])
    handled: List[str] = []

    class RecordingMessenger:
        def __init__(self, linked_engine: OmniLinkEngine):
            self.engine = linked_engine
            self.feedback: List[AgentFeedback] = []
            self.questions: List[AgentQuestion] = []
            self.waiters: List[PendingQuestion] = []
            self.acks: List[Tuple[str, Optional[str], Optional[Dict[str, Any]]]] = []

        def send_feedback(self, feedback: AgentFeedback) -> None:
            self.feedback.append(feedback)

        def ask_question(self, question: AgentQuestion) -> PendingQuestion:
            waiter = self.engine.create_question_waiter()
            self.questions.append(question)
            self.waiters.append(waiter)
            return waiter

        def acknowledge(
            self,
            status: str,
            *,
            message: Optional[str] = None,
            data: Optional[Dict[str, Any]] = None,
        ) -> None:
            self.acks.append((status, message, data))

    messenger = RecordingMessenger(engine)
    engine.set_messenger(messenger)

    def handler(evt: Dict[str, Any]) -> Dict[str, Any]:
        handled.append(evt["command"])
        msg = evt["messenger"]
        assert msg is messenger
        msg.acknowledge("in-progress", message="confirming")
        msg.send_feedback(AgentFeedback(message="checking", progress=0.25))
        waiter = msg.ask_question(
            AgentQuestion(prompt="Approve?", choices=["yes", "no"], data={"item": evt["vars"]["item"]})
        )
        return {"ack": True, "waiter": waiter.correlation_id}

    engine.on_template("confirm_[item:any]", handler)

    class DummyClient:
        def __init__(self) -> None:
            self.reply_payload: Optional[Dict[str, Any]] = None
            self.updated_payloads: List[Dict[str, Any]] = []
            self._records: List[Any] = [
                {
                    "last_command": "confirm widget",
                    "updated_at": "2024-02-01T00:00:00Z",
                    "user_key": "tester",
                },
                self._build_reply_record,
            ]

        def _build_reply_record(self) -> Optional[Dict[str, Any]]:
            if not self.reply_payload:
                return None
            return {
                "last_command": "confirm widget",
                "updated_at": "2024-02-01T00:00:00Z",
                "user_key": "tester",
                "last_response": json.dumps({"agentReplies": [self.reply_payload]}),
            }

        def fetch_last_command(self) -> Optional[Dict[str, Any]]:
            if not self._records:
                return None
            record = self._records.pop(0)
            if callable(record):
                return record()
            return record

        def update_last_response(self, response_payload: str, last_command: Optional[str] = None) -> None:
            try:
                decoded = json.loads(response_payload)
            except json.JSONDecodeError:
                decoded = {"raw": response_payload}
            self.updated_payloads.append(decoded)

    client = DummyClient()
    bridge = OmniLinkRemoteCommandBridge(engine, client=client, log=False)

    first = bridge.process_once()
    assert first and first.get("ok") is True
    assert handled == ["confirm widget"]
    assert messenger.feedback and messenger.feedback[0].message == "checking"
    assert messenger.acks and messenger.acks[0][0] == "in-progress"
    assert messenger.questions and messenger.questions[0].prompt == "Approve?"
    assert client.updated_payloads and client.updated_payloads[0]["result"]["ack"] is True

    waiter = messenger.waiters[0]
    client.reply_payload = {"correlationId": waiter.correlation_id, "answer": "yes"}

    second = bridge.process_once()
    assert second == {"agent_replies": 1}
    resolved = waiter.wait(timeout=0.1)
    assert resolved["answer"] == "yes"
    assert handled == ["confirm widget"]

def test_mqtt_bridge_merges_aux_fields_into_meta(monkeypatch):
    class DummyEngine:
        def __init__(self):
            self.seen = {}

        def handle(self, command, meta=None, messenger=None):
            self.seen = {"command": command, "meta": meta}
            return {"ok": True}

    class DummyMQTTClient:
        def __init__(self, *args, **kwargs):
            self.on_connect = None
            self.on_message = None
            self.published_payloads = []

        def username_pw_set(self, *_args, **_kwargs):
            pass

        def publish(self, *args, **kwargs):
            self.published_payloads.append((args, kwargs))

        def subscribe(self, *args, **kwargs):
            pass

    dummy_module = type("DummyMQTTModule", (), {"Client": DummyMQTTClient})
    monkeypatch.setattr("omnilink._mqtt", dummy_module, raising=False)

    engine = DummyEngine()
    bridge = OmniLinkMQTTBridge(engine, log=False)

    payload = {
        "command": "do something",
        "meta": {"agent": {"id": "alpha"}, "reply_to": "custom/reply"},
        "response": {"text": "ack"},
        "timestamp": "2024-01-01T00:00:00Z",
    }

    msg = type("DummyMsg", (), {"topic": bridge.command_topic})()
    bridge._handle_command_message(DummyMQTTClient(), msg, json.dumps(payload), payload)

    seen_meta = engine.seen["meta"]
    assert seen_meta["reply_to"] == "custom/reply"
    assert seen_meta["agent"]["id"] == "alpha"
    assert seen_meta["agent"]["response"] == payload["response"]
    assert seen_meta["agent"]["timestamp"] == payload["timestamp"]


def test_engine_exposes_messenger_to_handlers():
    engine = OmniLinkEngine(["hello"])
    seen = {}

    def before(evt):
        seen["messenger"] = evt.get("messenger")

    engine.before(before)
    engine.on_template(
        "hello",
        lambda evt: evt["messenger"].send_feedback(AgentFeedback(message="hi", ok=True)),
    )

    class DummyMessenger:
        def __init__(self, linked_engine: OmniLinkEngine):
            self.engine = linked_engine
            self.feedback: List[AgentFeedback] = []
            self.questions: List[AgentQuestion] = []
            self.waiters: List[PendingQuestion] = []
            self.acks: List[Tuple[str, Optional[str], Optional[Dict[str, Any]]]] = []

        def send_feedback(self, feedback: AgentFeedback) -> None:
            self.feedback.append(feedback)

        def ask_question(self, question: AgentQuestion) -> PendingQuestion:
            waiter = self.engine.create_question_waiter()
            self.questions.append(question)
            self.waiters.append(waiter)
            return waiter

        def acknowledge(
            self,
            status: str,
            *,
            message: Optional[str] = None,
            data: Optional[Dict[str, Any]] = None,
        ) -> None:
            self.acks.append((status, message, data))

    messenger = DummyMessenger(engine)
    engine.handle("hello", messenger=messenger)

    assert seen["messenger"] is messenger
    assert messenger.feedback and messenger.feedback[0].message == "hi"
    assert engine.history[-1].messenger is messenger


def test_pending_question_waits_for_reply():
    engine = OmniLinkEngine(["hello"])
    waiter = engine.create_question_waiter()
    payload = {"correlationId": waiter.correlation_id, "choice": "yes"}
    assert not waiter.done()
    handled = engine.receive_agent_reply(payload)
    assert handled is True
    resolved = waiter.wait(timeout=0.1)
    assert resolved["choice"] == "yes"
    assert waiter.done()
    assert waiter.result()["choice"] == "yes"


def test_pending_question_timeout_and_cancel():
    engine = OmniLinkEngine(["hello"])
    waiter = engine.create_question_waiter()
    with pytest.raises(TimeoutError):
        waiter.wait(timeout=0.01)
    waiter.cancel("abort")
    with pytest.raises(RuntimeError):
        waiter.wait(timeout=0.01)


def test_mqtt_bridge_agent_messenger_publishes(monkeypatch):
    class DummyMQTTClient:
        def __init__(self, *args, **kwargs):
            self.on_connect = None
            self.on_message = None
            self.published_payloads: List[Tuple[Tuple[Any, ...], Dict[str, Any]]] = []
            self.subscriptions: List[Tuple[Tuple[Any, ...], Dict[str, Any]]] = []

        def username_pw_set(self, *_args, **_kwargs):
            pass

        def publish(self, *args, **kwargs):
            self.published_payloads.append((args, kwargs))

        def subscribe(self, *args, **_kwargs):
            self.subscriptions.append((args, _kwargs))

    dummy_module = type("DummyMQTTModule", (), {"Client": DummyMQTTClient})
    monkeypatch.setattr("omnilink._mqtt", dummy_module, raising=False)

    engine = OmniLinkEngine(["build"])
    engine.last_waiter = None  # type: ignore[attr-defined]
    engine.last_call: Optional[Tuple[str, Dict[str, Any]]] = None  # type: ignore[attr-defined]

    def _handler(evt: Dict[str, Any]) -> Dict[str, Any]:
        messenger = evt["messenger"]
        messenger.acknowledge("received", message="working")
        messenger.send_feedback(AgentFeedback(message="progress", progress=0.5))
        engine.last_waiter = messenger.ask_question(  # type: ignore[attr-defined]
            AgentQuestion(prompt="continue?", choices=["yes", "no"])
        )
        engine.last_call = (evt["command"], evt.get("meta") or {})  # type: ignore[attr-defined]
        return {"handled": True}

    engine.on_template("build", _handler)
    bridge = OmniLinkMQTTBridge(engine, log=False)

    client = bridge.client  # type: ignore[assignment]
    payload = {"command": "build", "meta": {}}
    msg = type("DummyMsg", (), {"topic": bridge.command_topic})()
    bridge._handle_command_message(client, msg, json.dumps(payload), payload)

    topics = [call[0][0] for call in client.published_payloads]
    base = bridge.response_topic
    assert f"{base}/agent_ack" in topics
    assert f"{base}/agent_feedback" in topics
    assert f"{base}/agent_questions" in topics
    assert topics.count(base) >= 3  # legacy fallbacks for ack/question/feedback

    reply_topic = f"{base}/agent_replies"
    assert any(sub[0][0] == reply_topic for sub in client.subscriptions)

    waiter = engine.last_waiter
    assert waiter is not None
    reply_payload = {"correlationId": waiter.correlation_id, "answer": "yes"}
    bridge._handle_reply_message(json.dumps(reply_payload), reply_payload, reply_topic)
    resolved = waiter.wait(timeout=0.1)
    assert resolved["answer"] == "yes"

    decoded = [json.loads(call[0][1]) for call in client.published_payloads]
    assert any(item.get("status") == "received" for item in decoded if isinstance(item, dict))
    assert any(item.get("message") == "progress" for item in decoded if isinstance(item, dict))
    assert any(item.get("question") == "continue?" for item in decoded if isinstance(item, dict))
    assert engine.last_call and engine.last_call[0] == "build"
