"""What one replica knows about the fleet, read once for every tool that reports it (#1380).

`hangar_status` and `hangar_health` answer the same question -- which servers
and groups exist, and in what state -- and they used to answer it separately.
`hangar_status` counted hot-loaded servers and `hangar_health` did not, so one
replica could report 3 servers through one tool and 2 through the other. Both
now render from `observe_replica()`, so from one replica they cannot disagree
about the fleet.

**The answer is replica-local, and it says so.** Under session affinity an
operator does not pick the replica that answers. Two calls a minute apart can
reach two pods, one 35 seconds old with every server ready and one 80 minutes
old with most of them cold. Unless each response names the replica that
answered, the reader takes the difference between two pods for a change in the
fleet. So every response carries:

- `replica.instance_id` -- the identity minted at bootstrap
  (`HANGAR_INSTANCE_LABEL` or the hostname, plus a per-process suffix). It is
  the same value the management lease names as `holder`, the peer event tailer
  skips as its own, events carry as `produced_by`, traces carry as
  `service.instance.id`, and `GET /system` reports as `instance.instance_id`.
  One replica has one name everywhere.
- `replica.uptime_seconds` -- how long *this process* has run. Uptime is a
  property of a pod, not of the fleet.
- `scope: "replica"` and `scope_note`, which say it in words.

Nothing here asks peers or reads shared state. The fleet view belongs to
Prometheus, which already scrapes every replica; shared breaker and rotation
state is #1358 and is not a reporting concern.
"""

from __future__ import annotations

from dataclasses import dataclass
import time
from typing import Any

from ...application.read_models.mcp_server_views import DeadInfo, dead_info
from ...domain.events import current_instance_id
from ...infrastructure.runtime_store import LoadMetadata
from ..context import get_context

#: When this process started, as far as the tools can tell: the import of this
#: module, which happens while the server registers its tools. It used to live
#: in `hangar.py`; it moved here so every tool reports one uptime.
_PROCESS_STARTED_AT: float = time.time()

#: Value of `scope` on a replica-local answer. A string rather than a boolean,
#: so that a fleet-wide answer, if one is ever added, can name itself.
REPLICA_SCOPE = "replica"

#: Plain-words version of `scope`, for a reader who does not know the field.
SCOPE_NOTE = (
    "This describes what the replica named in replica.instance_id knows, not the fleet. "
    "Other replicas can report different state and uptime at the same moment. "
    "For a fleet-wide view, query the per-replica metrics in Prometheus."
)


@dataclass(frozen=True)
class ConfiguredServer:
    """One configured (repository) server as this replica sees it."""

    mcp_server_id: str
    state: str
    mode: str
    #: Why it is dead, while it is; the same `dead` `hangar_details` reports (#1418).
    dead: DeadInfo | None = None


@dataclass(frozen=True)
class HotLoadedServer:
    """One hot-loaded server as this replica sees it."""

    mcp_server_id: str
    state: str
    metadata: LoadMetadata
    #: Why it is dead, while it is (#1418).
    dead: DeadInfo | None = None


def _configured(mcp_server_id: str, server: Any) -> ConfiguredServer:
    state = server.state.value
    return ConfiguredServer(
        mcp_server_id=mcp_server_id, state=state, mode=server.mode.value, dead=dead_info(server, state)
    )


def _hot_loaded(server: Any, metadata: LoadMetadata) -> HotLoadedServer:
    state = server.state.value if hasattr(server, "state") else "unknown"
    return HotLoadedServer(
        mcp_server_id=str(server.mcp_server_id), state=state, metadata=metadata, dead=dead_info(server, state)
    )


@dataclass(frozen=True)
class GroupView:
    """One group as this replica sees it, from one `to_status_dict()` snapshot.

    The counts and `circuit_open` are the ones `hangar_group_list`,
    `hangar_details` and `GET /api/groups/{id}` report, taken under the group's
    lock, so they describe one instant (#1356).

    `state` is the group vocabulary (inactive, partial, healthy, degraded): the
    group's availability, computed from its members. It is not a server
    lifecycle state and is never mapped onto one (#1378).
    """

    group_id: str
    state: str
    healthy_count: int
    members_in_rotation_count: int
    total_count: int
    circuit_open: bool


@dataclass(frozen=True)
class ReplicaView:
    """One read of this replica's servers and groups, and which replica took it."""

    instance_id: str
    uptime_seconds: float
    configured: tuple[ConfiguredServer, ...]
    hot_loaded: tuple[HotLoadedServer, ...]
    groups: tuple[GroupView, ...]

    @property
    def server_states(self) -> list[str]:
        """The state of every server this replica runs, configured and hot-loaded."""
        return [s.state for s in self.configured] + [s.state for s in self.hot_loaded]

    @property
    def total_servers(self) -> int:
        """Number of servers this replica runs, configured and hot-loaded."""
        return len(self.configured) + len(self.hot_loaded)

    @property
    def ready_servers(self) -> int:
        """Number of those servers in the `ready` state."""
        return sum(1 for state in self.server_states if state == "ready")

    def servers_by_state(self) -> dict[str, int]:
        """Count of servers per state, over the same set as `total_servers`."""
        counts: dict[str, int] = {}
        for state in self.server_states:
            counts[state] = counts.get(state, 0) + 1
        return counts

    def replica_block(self) -> dict[str, Any]:
        """The `replica` field: which replica answered, and for how long it has run."""
        return {
            "instance_id": self.instance_id,
            "uptime_seconds": round(self.uptime_seconds, 1),
            "uptime": format_uptime(self.uptime_seconds),
        }

    def scope_fields(self) -> dict[str, Any]:
        """The fields every replica-local response carries: `replica`, `scope`, `scope_note`."""
        return {
            "replica": self.replica_block(),
            "scope": REPLICA_SCOPE,
            "scope_note": SCOPE_NOTE,
        }


def _group_view(group_id: str, status: dict[str, Any]) -> GroupView:
    """A group's `to_status_dict()`, cut down to what the status tools report."""
    return GroupView(
        group_id=group_id,
        state=status["state"],
        healthy_count=status["healthy_count"],
        members_in_rotation_count=status["members_in_rotation_count"],
        total_count=status["total_members"],
        circuit_open=status["circuit_open"],
    )


def observe_replica() -> ReplicaView:
    """Read this replica's servers, hot-loaded servers and groups, once.

    The only place either status tool reads fleet state from, so a change to
    what counts as "a server" reaches both tools or neither.

    Configured servers come from the repository itself, not through
    `ListMcpServersQuery`. That handler reads the same repository, but going
    through the bus would make `hangar_health` fail wherever the query handlers
    are not registered, which is exactly where an operator reaches for a
    health check. `hangar_health` has always read the repository directly;
    `hangar_status` now does too.
    """
    from ..state import get_runtime_mcp_servers

    ctx = get_context()
    configured = tuple(_configured(mcp_server_id, server) for mcp_server_id, server in ctx.repository.get_all().items())
    hot_loaded = tuple(_hot_loaded(server, metadata) for server, metadata in get_runtime_mcp_servers().list_all())
    groups = tuple(_group_view(group_id, group.to_status_dict()) for group_id, group in ctx.groups.items())
    return ReplicaView(
        instance_id=current_instance_id(),
        uptime_seconds=time.time() - _PROCESS_STARTED_AT,
        configured=configured,
        hot_loaded=hot_loaded,
        groups=groups,
    )


def format_uptime(seconds: float) -> str:
    """Format uptime as a human-readable string."""
    hours = int(seconds // 3600)
    minutes = int((seconds % 3600) // 60)
    if hours > 0:
        return f"{hours}h {minutes}m"
    return f"{minutes}m"
