"""Long-running agent runner for automatic in-thread replies."""

from __future__ import annotations

import json
import queue
import shlex
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable

from .api import list_interaction_records, send_message
from .config import B2A_DIR, load_json, save_json
from .crypto import _b64url_decode, sign_message
from .realtime import subscribe_to_conversations

RUNNER_STATE_FILE = B2A_DIR / "runner-state.json"
DEFAULT_MAX_PROCESSED = 2000


@dataclass(slots=True)
class IncomingMessage:
    """Normalized incoming message extracted from an interaction record."""

    interaction_id: str
    message_id: str
    sender_did: str
    recipient_did: str
    text: str
    created_at: str


def decode_payload_text(raw: str) -> str:
    """Decode the JSON text payload used by the current CLI/SDK."""
    decoded = _b64url_decode(raw or "")
    body = json.loads(decoded)
    text = body.get("text")
    return text if isinstance(text, str) else decoded.decode(errors="replace")


def parse_handler_output(output: str) -> tuple[str | None, bool]:
    """Accept either plain-text output or a JSON object from the handler."""
    raw = output.strip()
    if not raw:
        return None, False

    try:
        payload = json.loads(raw)
    except json.JSONDecodeError:
        return raw, False

    if not isinstance(payload, dict):
        raise ValueError("handler JSON output must be an object")

    reply = payload.get("reply", payload.get("message"))
    if reply is not None and not isinstance(reply, str):
        raise ValueError("handler reply must be a string")
    close_interaction = bool(payload.get("close_interaction") or payload.get("close"))
    return reply, close_interaction


class AgentRunner:
    """Subscribe to conversation updates, run a handler, and reply in-thread."""

    def __init__(
        self,
        *,
        did: str,
        token: str,
        handler_cmd: str,
        key_name: str = "default",
        sweep_interval: float = 30.0,
        history_limit: int = 100,
        handler_timeout: float = 60.0,
        state_path: Path | None = None,
        max_processed: int = DEFAULT_MAX_PROCESSED,
        logger: Callable[[str], None] | None = None,
        error_logger: Callable[[str], None] | None = None,
    ) -> None:
        self.did = did
        self.token = token
        self.key_name = key_name
        self.handler_cmd = shlex.split(handler_cmd)
        if not self.handler_cmd:
            raise ValueError("handler command cannot be empty")

        self.sweep_interval = max(0.0, sweep_interval)
        self.history_limit = max(1, history_limit)
        self.handler_timeout = max(1.0, handler_timeout)
        self.state_path = state_path or RUNNER_STATE_FILE
        self.max_processed = max(100, max_processed)
        self.logger = logger or (lambda msg: None)
        self.error_logger = error_logger or self.logger

        state = load_json(self.state_path)
        ids = state.get("processed_message_ids", [])
        self._processed_order = [msg_id for msg_id in ids if isinstance(msg_id, str)]
        self._processed_ids = set(self._processed_order)
        self._record_queue: queue.Queue[dict[str, Any]] = queue.Queue()

    def enqueue_record(self, record: dict[str, Any]) -> None:
        """Queue a realtime interaction record for processing."""
        self._record_queue.put(record)

    def _persist_state(self) -> None:
        save_json(self.state_path, {"processed_message_ids": self._processed_order})

    def _remember_processed(self, message_id: str) -> None:
        if message_id in self._processed_ids:
            return

        self._processed_ids.add(message_id)
        self._processed_order.append(message_id)
        overflow = len(self._processed_order) - self.max_processed
        if overflow > 0:
            expired = self._processed_order[:overflow]
            self._processed_order = self._processed_order[overflow:]
            for old_id in expired:
                self._processed_ids.discard(old_id)
        self._persist_state()

    def _extract_incoming_messages(self, record: dict[str, Any]) -> list[IncomingMessage]:
        interaction_id = str(record.get("interaction_id") or "")
        raw_messages = record.get("messages")
        if not interaction_id or not isinstance(raw_messages, list):
            return []

        incoming: list[IncomingMessage] = []
        for raw in raw_messages:
            if not isinstance(raw, dict):
                continue

            message_id = raw.get("message_id")
            sender_did = raw.get("sender_did")
            recipient_did = raw.get("recipient_did")
            if not isinstance(message_id, str) or not isinstance(sender_did, str) or not isinstance(recipient_did, str):
                continue
            if sender_did == self.did or recipient_did != self.did:
                continue
            if message_id in self._processed_ids:
                continue

            payload = raw.get("payload", "")
            if not isinstance(payload, str):
                continue
            try:
                text = decode_payload_text(payload)
            except Exception as exc:
                self.error_logger(f"Skipping undecodable message {message_id}: {exc}")
                self._remember_processed(message_id)
                continue

            incoming.append(
                IncomingMessage(
                    interaction_id=interaction_id,
                    message_id=message_id,
                    sender_did=sender_did,
                    recipient_did=recipient_did,
                    text=text,
                    created_at=str(raw.get("created_at") or ""),
                )
            )

        return incoming

    def _invoke_handler(self, message: IncomingMessage) -> tuple[str | None, bool]:
        handler_input = {
            "interaction_id": message.interaction_id,
            "message_id": message.message_id,
            "sender_did": message.sender_did,
            "recipient_did": message.recipient_did,
            "text": message.text,
            "created_at": message.created_at,
        }

        result = subprocess.run(
            self.handler_cmd,
            input=json.dumps(handler_input),
            text=True,
            capture_output=True,
            timeout=self.handler_timeout,
            check=False,
        )
        if result.returncode != 0:
            stderr = result.stderr.strip() or f"exit code {result.returncode}"
            raise RuntimeError(stderr)
        return parse_handler_output(result.stdout)

    def _send_reply(self, message: IncomingMessage, reply: str, *, close_interaction: bool) -> None:
        payload_bytes = json.dumps({"text": reply}).encode()
        envelope = sign_message(
            self.did,
            message.sender_did,
            payload_bytes,
            key_name=self.key_name,
        )
        send_message(
            self.token,
            self.did,
            message.sender_did,
            envelope,
            interaction_id=message.interaction_id,
            close_interaction=close_interaction,
        )

    def process_record(self, record: dict[str, Any]) -> int:
        """Process all unhandled inbound messages in one interaction record."""
        processed = 0
        for message in self._extract_incoming_messages(record):
            reply, close_interaction = self._invoke_handler(message)

            if reply:
                self._send_reply(message, reply, close_interaction=close_interaction)
                self._remember_processed(message.message_id)
                self.logger(f"Replied to {message.sender_did} on {message.interaction_id[:12]}...")
            elif close_interaction:
                self._remember_processed(message.message_id)
                self.logger(
                    f"Handler requested close for {message.interaction_id[:12]}..., "
                    "but no reply was sent because close-without-message is unsupported."
                )
            else:
                self._remember_processed(message.message_id)
                self.logger(f"Processed {message.message_id} without a reply.")
            processed += 1

        return processed

    def sweep_once(self) -> int:
        """Read recent interactions directly to recover any missed inbound messages."""
        records = list_interaction_records(self.token, self.did, limit=self.history_limit)
        processed = 0
        for record in reversed(records):
            processed += self.process_record(record)
        return processed

    def run_forever(self) -> None:
        """Start realtime processing, with periodic recovery sweeps."""
        def on_message(record: dict[str, Any]) -> None:
            self.enqueue_record(record)

        def on_error(err: str) -> None:
            self.error_logger(f"Realtime error: {err}")

        stop = subscribe_to_conversations(self.did, self.token, on_message, on_error)
        self.logger(f"Agent runner started for {self.did}")

        try:
            try:
                recovered = self.sweep_once()
                if recovered:
                    self.logger(f"Recovered {recovered} missed message(s) on startup.")
            except Exception as exc:
                self.error_logger(f"Initial recovery sweep failed: {exc}")

            next_sweep_at = time.monotonic() + self.sweep_interval
            while True:
                timeout = 1.0
                if self.sweep_interval > 0:
                    timeout = max(0.1, min(1.0, next_sweep_at - time.monotonic()))

                try:
                    record = self._record_queue.get(timeout=timeout)
                except queue.Empty:
                    record = None

                if record is not None:
                    try:
                        self.process_record(record)
                    except Exception as exc:
                        self.error_logger(f"Failed to process realtime record: {exc}")

                if self.sweep_interval > 0 and time.monotonic() >= next_sweep_at:
                    try:
                        recovered = self.sweep_once()
                        if recovered:
                            self.logger(f"Recovered {recovered} missed message(s) during sweep.")
                    except Exception as exc:
                        self.error_logger(f"Recovery sweep failed: {exc}")
                    next_sweep_at = time.monotonic() + self.sweep_interval
        finally:
            stop()
