"""Desktop notification bridge (Linux/D-Bus).

Lets Bolt react to things that happen without you: a build finishing, a
calendar alert, a message arriving. Notifications are tailed from
`dbus-monitor`, filtered, rate-limited, and handed to the controller, which
forwards them through the normal converse() path — so the pet can say
"your deploy just went green" instead of only ever answering questions.

Off by default (NOTIFICATION_BRIDGE): every forwarded notification is a
round trip to the server, and an unfiltered desktop can be very chatty.
NOTIFICATION_FILTER (a regex) is the main knob for keeping it useful.

The dbus-monitor *parsing* is pure and unit tested; only the subprocess
plumbing needs a real session bus.
"""

from __future__ import annotations

import platform
import re
import shutil
import subprocess
import threading
from dataclasses import dataclass
from typing import Callable, Iterable, Iterator, Optional

_STRING_LINE = re.compile(r'^\s*string\s+"(.*)"\s*$')
_BLOCK_START = re.compile(r"^(method call|signal|method return|error)\b")


@dataclass(frozen=True)
class Notification:
    app: str
    summary: str
    body: str

    def as_text(self) -> str:
        parts = [p for p in (self.summary, self.body) if p]
        joined = " — ".join(parts)
        return f"{self.app}: {joined}" if self.app else joined


def iter_notifications(lines: Iterable[str]) -> Iterator[Notification]:
    """Pull Notification records out of a `dbus-monitor` line stream.

    A Notify call prints its arguments one per line after the header; the
    string arguments arrive in the order app_name, app_icon, summary, body
    (replaces_id is a uint32, so it isn't in the string list). Anything
    that doesn't look like that is skipped rather than guessed at.
    """
    collecting = False
    strings: list[str] = []

    def _emit() -> Optional[Notification]:
        if len(strings) < 3:
            return None
        return Notification(app=strings[0].strip(), summary=strings[2].strip(),
                            body=(strings[3].strip() if len(strings) > 3 else ""))

    for line in lines:
        if _BLOCK_START.match(line):
            if collecting:
                notification = _emit()
                if notification is not None:
                    yield notification
            collecting = "member=Notify" in line
            strings = []
            continue
        if not collecting:
            continue
        match = _STRING_LINE.match(line)
        if match:
            strings.append(match.group(1))
    if collecting:
        notification = _emit()
        if notification is not None:
            yield notification


class NotificationGate:
    """Filter + rate limit. Clock is passed in (monotonic seconds) so the
    rate limiting is testable without sleeping."""

    def __init__(self, pattern: str = "", min_interval: float = 60.0):
        self._min_interval = max(0.0, min_interval)
        self._last_forwarded = None
        self._pattern = None
        if (pattern or "").strip():
            try:
                self._pattern = re.compile(pattern, re.IGNORECASE)
            except re.error:
                self._pattern = None  # a broken regex shouldn't mute everything

    def matches(self, notification: Notification) -> bool:
        if self._pattern is None:
            return True
        return bool(self._pattern.search(notification.as_text()))

    def should_forward(self, notification: Notification, now: float) -> bool:
        if not notification.as_text().strip():
            return False
        if not self.matches(notification):
            return False
        if self._last_forwarded is not None and now - self._last_forwarded < self._min_interval:
            return False
        self._last_forwarded = now
        return True


def available() -> bool:
    return platform.system() == "Linux" and shutil.which("dbus-monitor") is not None


class NotificationWatcher:
    """Tails dbus-monitor on a daemon thread, calling *callback* per
    notification. Best-effort: if the session bus isn't reachable it reports
    why via start() and stays off."""

    _ARGS = [
        "dbus-monitor", "--session",
        "interface='org.freedesktop.Notifications',member='Notify'",
    ]

    def __init__(self, callback: Callable[[Notification], None]):
        self._callback = callback
        self._process: Optional[subprocess.Popen] = None
        self._thread: Optional[threading.Thread] = None
        self._running = False

    def start(self) -> Optional[str]:
        """None on success, otherwise the reason the bridge is off."""
        if not available():
            return "notification bridge needs Linux + dbus-monitor — skipping"
        try:
            self._process = subprocess.Popen(
                self._ARGS, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
                text=True, bufsize=1,
            )
        except Exception as exc:
            return f"couldn't start dbus-monitor ({exc}) — notification bridge off"
        self._running = True
        self._thread = threading.Thread(target=self._pump, name="notification-bridge", daemon=True)
        self._thread.start()
        return None

    def _pump(self) -> None:
        assert self._process is not None and self._process.stdout is not None
        try:
            for notification in iter_notifications(self._process.stdout):
                if not self._running:
                    return
                try:
                    self._callback(notification)
                except Exception:
                    pass  # one bad notification shouldn't end the bridge
        except Exception:
            pass

    def stop(self) -> None:
        self._running = False
        if self._process is not None:
            try:
                self._process.terminate()
            except Exception:
                pass
            self._process = None
