"""Runtime loops for commander and worker roles."""

from __future__ import annotations

from dataclasses import dataclass
from dataclasses import replace
from datetime import datetime
from datetime import timezone
import json
import os
import signal
import threading
import time
import uuid

from a2a_agent.client import MqttClient
from a2a_agent.config import AgentConfig
from a2a_agent.config import BrokerConfig
from a2a_agent.config import DeviceRole
from a2a_agent.config import load_config
from a2a_agent.config import save_config
from a2a_agent.executor import execute_text
from a2a_agent.executor import ExecutorResult
from a2a_agent.executor import selected_executor
from a2a_agent.invite import validate_pairing_code
from a2a_agent.pairing import consume_pending_pairing
from a2a_agent.protocol import AgentCard
from a2a_agent.protocol import Message
from a2a_agent.protocol import PairingReply
from a2a_agent.protocol import PairingRequest
from a2a_agent.protocol import Task
from a2a_agent.updater import start_runtime_auto_update_monitor
from a2a_agent import __version__
from a2a_agent import topics


PAIRING_REPLY_TIMEOUT_SECONDS = 30
SEND_TIMEOUT_SECONDS = int(os.environ.get("A2A_SEND_TIMEOUT", "120"))


@dataclass(frozen=True)
class SendResult:
    task_id: str
    status: str
    text: str


def join_pairing(code: str, *, timeout: int = PAIRING_REPLY_TIMEOUT_SECONDS) -> str:
    normalized = validate_pairing_code(code)
    broker = BrokerConfig()
    reply_event = threading.Event()
    reply_holder: dict[str, PairingReply] = {}
    client = MqttClient(client_id=f"a2a-join-{uuid.uuid4().hex}")
    reply_topic = topics.pairing_reply_topic(normalized)

    def handle_reply(topic: str, payload: bytes, message: object) -> None:
        try:
            reply_holder["reply"] = PairingReply.from_json(payload)
        except (ValueError, KeyError, json.JSONDecodeError) as exc:
            reply_holder["reply"] = PairingReply(ok=False, error=str(exc))
        reply_event.set()

    client.connect(broker.host, broker.port, tls=broker.tls)
    client.loop_start()
    try:
        if not client.wait_connected():
            raise RuntimeError(f"could not connect to broker {broker.host}:{broker.port}")
        client.subscribe(reply_topic, handle_reply)
        time.sleep(0.2)
        _publish_and_wait(
            client,
            topics.pairing_request_topic(),
            PairingRequest(code=normalized).to_json(),
        )
        if not reply_event.wait(timeout):
            raise TimeoutError(f"pairing reply timed out after {timeout} seconds")

        reply = reply_holder["reply"]
        if not reply.ok:
            raise RuntimeError(reply.error or "pairing failed")
        if not reply.room or not reply.device_name or not reply.token or not reply.broker:
            raise RuntimeError("pairing reply missed required worker settings")

        broker_payload = reply.broker
        saved_path = save_config(
            AgentConfig(
                room=reply.room,
                device_name=reply.device_name,
                broker=BrokerConfig.from_dict(broker_payload),
                role="worker",
                token=reply.token,
            )
        )
        return str(saved_path)
    finally:
        client.disconnect()
        client.loop_stop()


def run_commander(stop_event: threading.Event | None = None) -> None:
    config = load_config()
    if config.role != "commander":
        raise RuntimeError("commander run requires commander config")

    stop = stop_event or threading.Event()
    _install_signal_handlers(stop)
    status_topic = topics.agent_status_topic(config.room, config.device_name)
    client = _connected_client(config, "commander", status_topic)

    def handle_pairing_request(topic: str, payload: bytes, message: object) -> None:
        try:
            request = PairingRequest.from_json(payload)
        except (ValueError, KeyError, json.JSONDecodeError) as exc:
            print(f"pairing request ignored: {exc}", flush=True)
            return

        current = load_config()
        reply, updated = consume_pending_pairing(current, request.code)
        # 페어링에 성공하면 그 기기를 커맨더 config에 영구 등록한다.
        # (재시작 후에도 어떤 기기가 있었는지 기억하고, 개별 지시가 가능하게)
        if reply.ok and reply.device_name and reply.device_name not in updated.devices:
            new_devices = dict(updated.devices)
            new_devices[reply.device_name] = DeviceRole(
                role="",
                description=f"paired {datetime.now(timezone.utc).isoformat()}",
            )
            updated = replace(updated, devices=new_devices)
        if updated != current:
            save_config(updated)
        _publish_and_wait(client, topics.pairing_reply_topic(request.code), reply.to_json())
        device = reply.device_name or "-"
        print(
            f"pairing reply sent: code={request.code} ok={str(reply.ok).lower()} "
            f"device={device}",
            flush=True,
        )

    def handle_worker_status(topic: str, payload: bytes, message: object) -> None:
        try:
            decoded = payload.decode("utf-8")
        except UnicodeDecodeError:
            decoded = "<binary>"
        print(f"worker status: topic={topic} payload={decoded}", flush=True)

    try:
        client.subscribe(topics.pairing_request_topic(), handle_pairing_request)
        client.subscribe(topics.agent_status_filter(config.room), handle_worker_status)
        _publish_and_wait(client, status_topic, _status_payload(config, "online"), retain=True)
        print(
            f"commander run ready: broker={config.broker.host}:{config.broker.port} "
            f"room={config.room} device={config.device_name}",
            flush=True,
        )
        _wait_until_stopped(stop)
    finally:
        _publish_and_wait(client, status_topic, _status_payload(config, "offline"), retain=True)
        client.disconnect()
        client.loop_stop()


def run_worker(stop_event: threading.Event | None = None) -> None:
    config = load_config()
    if config.role != "worker":
        raise RuntimeError("worker run requires worker config")
    if not config.token:
        raise RuntimeError("worker config has no token; run join first")

    stop = stop_event or threading.Event()
    _install_signal_handlers(stop)
    status_topic = topics.agent_status_topic(config.room, config.device_name)
    client = _connected_client(config, "worker", status_topic)
    request_topic = topics.cmd_request_topic(config.room, config.device_name)
    response_topic = topics.cmd_response_topic(config.room, config.device_name)
    task_status_topic = topics.cmd_task_status_topic(config.room, config.device_name)

    def handle_request(topic: str, payload: bytes, message: object) -> None:
        try:
            task = Task.from_json(payload)
        except (ValueError, KeyError, json.JSONDecodeError) as exc:
            print(f"task ignored: {exc}", flush=True)
            return

        print(f"task received: id={task.id} from={task.from_}", flush=True)
        working = replace(task, from_=config.device_name, to=task.from_, status="working")
        _publish_and_wait(client, task_status_topic, working.to_json())

        try:
            result = execute_text(_task_text(task), config)
        except Exception as exc:  # 워커가 어떤 실행기 오류에도 죽지 않게 한다.
            result = ExecutorResult(ok=False, text=f"executor crashed: {exc}")
        status = "completed" if result.ok else "failed"
        response = Task(
            id=task.id,
            from_=config.device_name,
            to=task.from_,
            status=status,
            messages=[
                Message(
                    role="agent",
                    parts=[{"type": "text", "text": result.text}],
                )
            ],
        )
        _publish_and_wait(client, response_topic, response.to_json())
        done = replace(response, messages=[])
        _publish_and_wait(client, task_status_topic, done.to_json())
        print(f"task responded: id={task.id} status={status}", flush=True)

    try:
        client.subscribe(request_topic, handle_request)
        _publish_and_wait(
            client,
            topics.agent_card_topic(config.room, config.device_name),
            AgentCard(name=config.device_name).to_json(),
            retain=True,
        )
        _publish_and_wait(client, status_topic, _status_payload(config, "online"), retain=True)
        start_runtime_auto_update_monitor(stop)
        print(
            f"worker run ready: broker={config.broker.host}:{config.broker.port} "
            f"room={config.room} device={config.device_name} "
            f"executor={selected_executor(config)}",
            flush=True,
        )
        _wait_until_stopped(stop)
    finally:
        _publish_and_wait(client, status_topic, _status_payload(config, "offline"), retain=True)
        client.disconnect()
        client.loop_stop()


def send_task(
    device_name: str,
    text: str,
    *,
    timeout: int = SEND_TIMEOUT_SECONDS,
) -> SendResult:
    config = load_config()
    if config.role != "commander":
        raise RuntimeError("send requires commander config")
    resolved_device_name = resolve_device_name(config, device_name)

    task_id = f"task-{uuid.uuid4().hex}"
    response_event = threading.Event()
    response_holder: dict[str, Task] = {}
    client = MqttClient(client_id=f"a2a-send-{uuid.uuid4().hex}")
    response_topic = topics.cmd_response_topic(config.room, resolved_device_name)

    def handle_response(topic: str, payload: bytes, message: object) -> None:
        try:
            task = Task.from_json(payload)
        except (ValueError, KeyError, json.JSONDecodeError):
            return
        if task.id != task_id:
            return
        response_holder["task"] = task
        response_event.set()

    client.connect(config.broker.host, config.broker.port, tls=config.broker.tls)
    client.loop_start()
    try:
        if not client.wait_connected():
            raise RuntimeError(
                f"could not connect to broker {config.broker.host}:{config.broker.port}"
            )
        client.subscribe(response_topic, handle_response)
        time.sleep(0.2)
        request = Task(
            id=task_id,
            from_=config.device_name,
            to=resolved_device_name,
            status="pending",
            messages=[
                Message(role="user", parts=[{"type": "text", "text": text}]),
            ],
        )
        _publish_and_wait(
            client,
            topics.cmd_request_topic(config.room, resolved_device_name),
            request.to_json(),
        )
        if not response_event.wait(timeout):
            raise TimeoutError(
                f"response from {resolved_device_name} timed out after {timeout} seconds"
            )

        response = response_holder["task"]
        return SendResult(
            task_id=response.id,
            status=response.status,
            text=_task_text(response),
        )
    finally:
        client.disconnect()
        client.loop_stop()


def resolve_device_name(config: AgentConfig, requested_name: str) -> str:
    if requested_name in config.aliases:
        return config.aliases[requested_name]
    if requested_name in config.devices or not config.devices:
        return requested_name
    aliases = ", ".join(sorted(config.aliases)) or "none"
    devices = ", ".join(sorted(config.devices)) or "none"
    raise RuntimeError(
        f"unknown device or alias: {requested_name} "
        f"(aliases: {aliases}; devices: {devices})"
    )


def _connected_client(config: AgentConfig, role: str, status_topic: str) -> MqttClient:
    client = MqttClient(client_id=f"a2a-{role}-{uuid.uuid4().hex}")
    client.set_last_will(
        status_topic,
        _status_payload(config, "offline"),
        retain=True,
    )
    client.connect(config.broker.host, config.broker.port, tls=config.broker.tls)
    client.loop_start()
    if not client.wait_connected():
        raise RuntimeError(f"could not connect to broker {config.broker.host}:{config.broker.port}")
    return client


def _publish_and_wait(
    client: MqttClient,
    topic: str,
    payload: str,
    *,
    retain: bool = False,
) -> None:
    info = client.publish(topic, payload, qos=1, retain=retain)
    info.wait_for_publish(timeout=10)


def _task_text(task: Task) -> str:
    parts: list[str] = []
    for message in task.messages:
        for part in message.parts:
            if part.get("type") == "text" and part.get("text") is not None:
                parts.append(str(part["text"]))
    return "\n".join(parts)


def _status_payload(config: AgentConfig, status: str) -> str:
    payload = {
        "device_name": config.device_name,
        "role": config.role,
        "version": __version__,
        "executor": selected_executor(config),
        "status": status,
        "ts": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
    }
    return json.dumps(payload, ensure_ascii=False, sort_keys=True)


def _wait_until_stopped(stop_event: threading.Event) -> None:
    try:
        while not stop_event.wait(1):
            pass
    except KeyboardInterrupt:
        stop_event.set()


def _install_signal_handlers(stop_event: threading.Event) -> None:
    def handle_signal(signum: int, frame: object) -> None:
        stop_event.set()

    try:
        signal.signal(signal.SIGTERM, handle_signal)
        signal.signal(signal.SIGINT, handle_signal)
    except ValueError:
        return
