"""Which screens exist, and which one the pet is standing on.

Deliberately free of Qt *and* of any subprocess probing: the monitor list is
published by the UI (`ui/pet_window.py` builds it from
`QGuiApplication.screens()`) and handed to the controller over a queued
signal, the same way every other UI↔controller message travels.

That indirection is the whole point. The pet has to agree with itself about
what "monitor 2" means — if the controller enumerated screens with `xrandr`
while the window jumped using Qt's screen list, the two orderings could
disagree and Bolt would announce one screen and land on another. Making Qt the
single source of truth removes that class of bug, and leaves everything here
pure enough to unit test without a display.

Indices are **1-based in every string a human or the model ever sees**, and
0-based in the list itself. `Monitor.index` is the 0-based one; `.number` is
what gets printed.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Iterable, Optional

# Directional specs understood by resolve(), mapped to a (dx, dy) heading.
_DIRECTIONS = {
    "left": (-1, 0),
    "right": (1, 0),
    "up": (0, -1),
    "above": (0, -1),
    "down": (0, 1),
    "below": (0, 1),
}


@dataclass(frozen=True)
class Monitor:
    """One screen, in the global desktop coordinate space."""

    index: int  # 0-based position in the published list
    name: str
    x: int
    y: int
    width: int
    height: int
    primary: bool = False

    @property
    def number(self) -> int:
        """1-based, for anything a person or the model reads."""
        return self.index + 1

    @property
    def right(self) -> int:
        return self.x + self.width

    @property
    def bottom(self) -> int:
        return self.y + self.height

    @property
    def center(self) -> tuple[int, int]:
        return self.x + self.width // 2, self.y + self.height // 2

    def contains(self, x: int, y: int) -> bool:
        return self.x <= x < self.right and self.y <= y < self.bottom

    @property
    def label(self) -> str:
        bits = f"{self.number}: {self.name} {self.width}x{self.height}"
        return bits + " (primary)" if self.primary else bits


def monitor_containing(
    monitors: Iterable[Monitor], x: int, y: int
) -> Optional[Monitor]:
    """The screen holding point (x, y), or None if it's off every screen."""
    for monitor in monitors:
        if monitor.contains(x, y):
            return monitor
    return None


def nearest_monitor(monitors: Iterable[Monitor], x: int, y: int) -> Optional[Monitor]:
    """Screen whose centre is closest to (x, y) — the fallback when a point
    lands in the dead space between mismatched screens."""
    monitors = list(monitors)
    if not monitors:
        return None
    return min(
        monitors,
        key=lambda m: (m.center[0] - x) ** 2 + (m.center[1] - y) ** 2,
    )


def resolve(
    monitors: list[Monitor], spec: str, current: Optional[int] = None
) -> Monitor:
    """Turn a `petctl jump` target into a screen.

    Accepts a 1-based number, a name (case-insensitive substring, so "hdmi"
    finds "HDMI-0"), `next`/`prev`, `primary`, `other`, or a direction
    (`left`/`right`/`up`/`down`) relative to *current*. Raises ValueError with
    a message meant to be read by the model, since it goes back as tool
    output.
    """
    if not monitors:
        raise ValueError("no monitors have been reported yet")
    spec = (spec or "").strip().lower()
    if not spec:
        raise ValueError("jump needs a target monitor")
    count = len(monitors)
    if current is None or not (0 <= current < count):
        current = next((m.index for m in monitors if m.primary), 0)

    if spec.isdigit():
        number = int(spec)
        if not (1 <= number <= count):
            raise ValueError(
                f"there is no monitor {number}; you have {count} "
                f"(1-{count})"
            )
        return monitors[number - 1]

    if spec in ("next", "forward"):
        return monitors[(current + 1) % count]
    if spec in ("prev", "previous", "back"):
        return monitors[(current - 1) % count]
    if spec == "primary":
        return next((m for m in monitors if m.primary), monitors[0])
    if spec == "other":
        # With two screens "the other one" is unambiguous; with more it's just
        # the next one round, which is at least always a *different* screen.
        return monitors[(current + 1) % count]
    if spec == "random":
        # Deterministic-free choice is the caller's business; pick the screen
        # furthest from the current one so "random" always visibly moves.
        here = monitors[current].center
        return max(
            monitors,
            key=lambda m: (m.center[0] - here[0]) ** 2 + (m.center[1] - here[1]) ** 2,
        )

    if spec in _DIRECTIONS:
        dx, dy = _DIRECTIONS[spec]
        here = monitors[current].center
        candidates = []
        for monitor in monitors:
            if monitor.index == current:
                continue
            ox, oy = monitor.center
            along = (ox - here[0]) * dx + (oy - here[1]) * dy
            if along <= 0:
                continue  # not in that direction at all
            drift = abs((ox - here[0]) * dy + (oy - here[1]) * dx)
            candidates.append((drift, along, monitor))
        if not candidates:
            raise ValueError(
                f"there's no monitor to the {spec} of monitor "
                f"{monitors[current].number}"
            )
        # Prefer the best-aligned screen, then the closest of those.
        candidates.sort(key=lambda item: (item[0], item[1]))
        return candidates[0][2]

    matches = [m for m in monitors if spec in m.name.lower()]
    if len(matches) == 1:
        return matches[0]
    if len(matches) > 1:
        raise ValueError(
            f"{spec!r} matches several monitors: "
            + ", ".join(m.label for m in matches)
        )
    raise ValueError(
        f"unknown monitor {spec!r}; you have: "
        + "; ".join(m.label for m in monitors)
        + " — or use next/prev/primary/left/right/up/down"
    )


def describe(monitors: list[Monitor], current: Optional[int] = None) -> str:
    """Full listing, used as `petctl monitors` output."""
    if not monitors:
        return "[pet] no monitor information available"
    lines = [f"[pet] {len(monitors)} monitor(s):"]
    for monitor in monitors:
        here = " <- Bolt is here" if monitor.index == current else ""
        lines.append(
            f"  {monitor.label} at +{monitor.x}+{monitor.y}{here}"
        )
    return "\n".join(lines)


def summary(monitors: list[Monitor], current: Optional[int] = None) -> Optional[str]:
    """One-line version tacked onto each utterance — short on purpose, since
    it rides along with every single thing you say."""
    if not monitors:
        return None
    parts = ", ".join(f"{m.number}) {m.name} {m.width}x{m.height}" for m in monitors)
    line = f"{len(monitors)} monitors: {parts}"
    if current is not None and 0 <= current < len(monitors):
        line += f"; Bolt is on {monitors[current].number}"
    return line


def annotate(text: str, monitors: list[Monitor], current: Optional[int] = None) -> str:
    """Attach the screen summary as a separate aside, matching the style of
    screen_context.annotate() so the model can ignore it when irrelevant."""
    text = (text or "").strip()
    line = summary(monitors, current)
    if not text or not line:
        return text
    return f"{text}\n\n[{line}]"
