"""The perspective engine (plan §4.2) — the soul of Volleyboard.

Same underlying data, a different game per viewer. Three pure projections over
the team tree, plus touch/comment visibility:

- courts_of(V)        the courts a viewer plays on (their strip bands)
- visible_players(V)  who a viewer can see as individuals
- rep(P, V)           who player P renders AS to viewer V (themselves, a
                      boundary lead they collapse into, or a side label)
- touch_visible(A, V) is a touch/comment by actor A shown to V (actor-based,
                      §4.3) — invisible stretches collapse to a labelled line

Loaded once per request as a MatchTopology. rep() is a read-time projection
over append-only history: restructure the tree and past rallies re-render
correctly under the new structure.

The fleshout worked example is the test suite (validated at build time and,
at M6, as tests/test_perspective.py). Every assertion there is derivable
from these functions.

M6 additions (all additive — the three spec functions above are untouched):

- OBSERVER DEFAULT-DENY. A viewer with no team membership in the match (no
  Player row, or a player outside the team tree) is an OUTSIDE OBSERVER and
  gets the MOST-COLLAPSED view: the big court only, front-line-level data of
  both sides. They never see a sub-team's players, touches, or comments.
  effective_courts()/effective_visible() are the observer-aware entry points
  the projection layer uses; courts_of()/visible_players() stay exactly the
  §4.2 spec functions for participants.

- COMMENT SCOPING RULE (§4.3, "comments are scoped, not just anonymized"):
  a comment is visible to V iff its acting_node_id is NULL (legacy/outside —
  written with no team context, nothing to hide) OR the node is one of V's
  visible nodes: the node of each court in effective_courts(V), plus the
  node(s) of that court's far players (the far boundary stands across YOUR
  net, so what they say in their own front-line context is part of your
  game). Anything else renders as a collapsed count — no bodies, no authors.
  Note the deliberate consequence: node visibility is per-NODE, not per-
  author — Rob can see a front-line comment written by Tracy (his far
  boundary's node) even though Tracy herself is invisible to him; her
  authorship then renders through rep() as the side label.

- PerspectiveLens: a per-(viewer, request) memo of courts/visible/nodes so
  bulk payloads (the board read) project without recomputing set unions per
  ball — topology is loaded ONCE per request, lens math is pure in-memory.
"""
from __future__ import annotations

from dataclasses import dataclass

from sqlalchemy.orm import Session

from ..models import Side, TeamMember, TeamNode


@dataclass(frozen=True)
class Rep:
    """Who a player renders as to a viewer. Either a concrete player, or a
    side label (the player is beyond the viewer's deepest visible net)."""

    kind: str  # "player" | "side"
    player_id: str | None = None
    label: str | None = None


@dataclass(frozen=True)
class CourtView:
    node_id: str
    name: str
    is_root: bool
    near: frozenset  # player ids on the viewer's side of this court
    far: frozenset  # visible opponents as individuals
    far_label: str  # what's shown across this net


class MatchTopology:
    """The team tree of one match, with the perspective projections."""

    def __init__(self, db: Session, match_id) -> None:
        self.match_id = match_id
        # Sides in position order: observer_court() and the board's side
        # rows both depend on a deterministic "side 1 / side 2".
        sides = (
            db.query(Side)
            .filter(Side.match_id == match_id)
            .order_by(Side.position, Side.name)
            .all()
        )
        nodes = (
            db.query(TeamNode)
            .join(Side, TeamNode.side_id == Side.id)
            .filter(Side.match_id == match_id)
            .all()
        )
        members = db.query(TeamMember).filter(TeamMember.match_id == match_id).all()

        self.side_name = {s.id: s.name for s in sides}
        self.side_position = {s.id: s.position for s in sides}
        self._side_ids = [s.id for s in sides]
        self.node = {n.id: n for n in nodes}
        self.members_by_node: dict = {n.id: set() for n in nodes}
        self.node_by_player: dict = {}
        for m in members:
            self.members_by_node.setdefault(m.node_id, set()).add(m.player_id)
            self.node_by_player[m.player_id] = m.node_id
        # Front-line (root) node per side = the one with no parent.
        self.root_node_by_side: dict = {}
        for n in nodes:
            if n.parent_node_id is None:
                self.root_node_by_side[n.side_id] = n.id

    # --- tree helpers ---
    def is_root(self, node_id) -> bool:
        return self.node[node_id].parent_node_id is None

    def parent(self, node_id):
        return self.node[node_id].parent_node_id

    def boundary(self, node_id):
        return self.node[node_id].boundary_player_id

    def side_of(self, node_id):
        return self.node[node_id].side_id

    def members(self, node_id) -> set:
        return self.members_by_node.get(node_id, set())

    def opposing_side(self, side_id):
        for s in self._side_ids:
            if s != side_id:
                return s
        return None

    def front_line(self, side_id):
        return self.root_node_by_side.get(side_id)

    def root_side_name(self, node_id) -> str:
        return self.side_name.get(self.side_of(node_id), "the other side")

    # --- court(N) ---
    def court(self, node_id) -> CourtView:
        n = self.node[node_id]
        if n.parent_node_id is None:  # root / front-line
            opp = self.opposing_side(n.side_id)
            far = self.members(self.front_line(opp)) if opp else set()
            far_label = self.side_name.get(opp, "the other side")
        else:  # sub-team: the opponent is its own boundary (parent context)
            b = self.boundary(node_id)
            far = {b} if b else set()
            far_label = self.root_side_name(node_id)
        return CourtView(
            node_id=node_id,
            name=n.name,
            is_root=(n.parent_node_id is None),
            near=frozenset(self.members(node_id)),
            far=frozenset(far),
            far_label=far_label,
        )

    # --- courts_of(V): member node + any node you're the boundary (lead) of ---
    def courts_of(self, viewer_id) -> list[CourtView]:
        node_ids: list = []
        mnode = self.node_by_player.get(viewer_id)
        if mnode is not None:
            node_ids.append(mnode)
        for nid, n in self.node.items():
            if n.boundary_player_id == viewer_id and nid not in node_ids:
                node_ids.append(nid)
        return [self.court(nid) for nid in node_ids]

    # --- visible_players(V) ---
    def visible_players(self, viewer_id) -> set:
        vp: set = {viewer_id}
        for c in self.courts_of(viewer_id):
            vp |= set(c.near) | set(c.far)
        return vp

    def _member_court_far_label(self, viewer_id) -> str:
        mnode = self.node_by_player.get(viewer_id)
        return self.court(mnode).far_label if mnode is not None else "the other side"

    # --- rep(P, V) ---
    def rep(self, player_id, viewer_id) -> Rep:
        vp = self.visible_players(viewer_id)
        if player_id in vp:
            return Rep(kind="player", player_id=player_id)
        n = self.node_by_player.get(player_id)
        while n is not None and not self.is_root(n):
            b = self.boundary(n)
            if b in vp:
                return Rep(kind="player", player_id=b)
            n = self.parent(n)
        return Rep(kind="side", label=self._member_court_far_label(viewer_id))

    # --- touch/comment visibility (§4.3): actor-based ---
    def touch_visible(self, actor_id, viewer_id) -> bool:
        """A touch/comment by actor A is shown to V (with real author) iff A is
        directly visible to V. Otherwise it's part of a collapsed stretch."""
        return actor_id in self.visible_players(viewer_id)

    def collapsed_label(self, acting_node_id) -> str:
        if acting_node_id is None:
            return "the other side"
        return f"the {self.root_side_name(acting_node_id)} side"

    # ------------------------------------------------------------------
    # M6 additions (observer default-deny + node visibility + autopass
    # topology). All additive; the §4.2 spec functions above are untouched.
    # ------------------------------------------------------------------

    def is_participant(self, viewer_id) -> bool:
        """Does this viewer play in the match at all (member of a node, or
        boundary of one)? None (no Player row) is never a participant."""
        if viewer_id is None:
            return False
        if viewer_id in self.node_by_player:
            return True
        return any(n.boundary_player_id == viewer_id for n in self.node.values())

    def observer_court(self) -> CourtView | None:
        """The most-collapsed view — the big net seen from the first side's
        bench (sides ordered by position at load): near = side 1's front
        line, far = side 2's, far_label = side 2's name. This is what an
        OUTSIDE OBSERVER (no membership in this match) gets: front-line-
        level data of both sides, nothing below either root. None only for
        a match with no team tree yet."""
        for side_id in self._side_ids:
            root = self.front_line(side_id)
            if root is not None:
                return self.court(root)
        return None

    def effective_courts(self, viewer_id) -> list[CourtView]:
        """courts_of() for participants; the observer court for everyone
        else (default-deny: an unknown viewer sees the most-collapsed view,
        never MORE than a front-liner)."""
        if self.is_participant(viewer_id):
            return self.courts_of(viewer_id)
        court = self.observer_court()
        return [court] if court is not None else []

    def effective_visible(self, viewer_id) -> set:
        """visible_players() for participants (which includes the viewer,
        so self-mention/self-assign keep working); for an observer, the two
        front lines only — the observer is not ON the court, so they are
        deliberately not added to their own visible set."""
        if self.is_participant(viewer_id):
            return self.visible_players(viewer_id)
        vp: set = set()
        for c in self.effective_courts(viewer_id):
            vp |= set(c.near) | set(c.far)
        return vp

    def visible_nodes(self, viewer_id) -> set:
        """The node set that scopes comments for this viewer (module
        docstring: each effective court's own node + its far players'
        nodes). Pure set math over the already-loaded tree."""
        nodes: set = set()
        for c in self.effective_courts(viewer_id):
            nodes.add(c.node_id)
            for p in c.far:
                far_node = self.node_by_player.get(p)
                if far_node is not None:
                    nodes.add(far_node)
        return nodes

    def node_visible(self, viewer_id, node_id) -> bool:
        """Is a comment written in node_id's context shown to viewer_id?
        NULL node = legacy/outside context = visible to everyone (nothing
        was scoped when it was written)."""
        if node_id is None:
            return True
        return node_id in self.visible_nodes(viewer_id)

    def collapsed_label_for(self, viewer_id, acting_node_id) -> str:
        """collapsed_label(), viewer-aware. A viewer with a ROOT court in
        their strip (front-liners, leads, observers) knows both side names,
        so they get the real "the <side> side". A SUB-TEAM-ONLY viewer's
        world ends at their own net (§4.2: naming the opposing side to
        Zeina would leak structure her game doesn't contain) — everything
        beyond collapses to their court's far_label."""
        courts = self.effective_courts(viewer_id)
        if any(c.is_root for c in courts):
            return self.collapsed_label(acting_node_id)
        far_label = courts[0].far_label if courts else "the other side"
        return f"the {far_label} side"

    def led_by(self, owner_id, player_id) -> bool:
        """Autopass "from_subteam" topology check (plan §4.4): is
        player_id on a team owner_id leads? True iff player_id's node — or
        any ancestor of it — has owner_id as its boundary player.
        Inclusive of the player's own node: Zeina (member of Tingang,
        boundary Adi) IS Adi's sub-team; that inclusivity is what makes
        the fleshout scenario (zeina -> adi auto -> rian) fire."""
        n = self.node_by_player.get(player_id)
        while n is not None:
            node = self.node[n]
            if node.boundary_player_id == owner_id:
                return True
            n = node.parent_node_id
        return False


class PerspectiveLens:
    """One viewer's projection over one MatchTopology, memoized for a
    request. The board read projects hundreds of balls; every set here is
    computed ONCE and rep() results are cached per player id, so bulk
    projection stays O(rows), never O(rows x courts). viewer_id may be
    None (a signed-in BW user with no Player row) — that is the observer
    default-deny path."""

    def __init__(self, topology: MatchTopology, viewer_id) -> None:
        self.topology = topology
        self.viewer_id = viewer_id
        self.courts = topology.effective_courts(viewer_id)
        self.visible = topology.effective_visible(viewer_id)
        self.nodes = topology.visible_nodes(viewer_id)
        # The side-label rep() bottoms out at: the far_label of the
        # viewer's MEMBER court (§4.2's "the far-side label of V's own
        # court"); observers fall back to the observer court's far_label.
        member_node = topology.node_by_player.get(viewer_id)
        if member_node is not None:
            self.bottom_label = topology.court(member_node).far_label
        elif self.courts:
            self.bottom_label = self.courts[0].far_label
        else:
            self.bottom_label = "the other side"
        self._rep_cache: dict = {}

    def rep(self, player_id) -> Rep:
        """rep(P, V) with V fixed and the visible set precomputed —
        identical semantics to MatchTopology.rep for participants, and the
        observer-aware visible set for everyone else."""
        if player_id in self._rep_cache:
            return self._rep_cache[player_id]
        topo = self.topology
        if player_id in self.visible:
            result = Rep(kind="player", player_id=player_id)
        else:
            result = None
            n = topo.node_by_player.get(player_id)
            while n is not None and not topo.is_root(n):
                b = topo.boundary(n)
                if b in self.visible:
                    result = Rep(kind="player", player_id=b)
                    break
                n = topo.parent(n)
            if result is None:
                result = Rep(kind="side", label=self.bottom_label)
        self._rep_cache[player_id] = result
        return result

    def player_visible(self, player_id) -> bool:
        return player_id in self.visible

    def touch_visible(self, from_player) -> bool:
        """Actor-based touch visibility (§4.3). An authorless touch
        (from_player NULL — e.g. a system serve) has no actor to hide and
        stays visible."""
        return from_player is None or from_player in self.visible

    def node_visible(self, node_id) -> bool:
        return node_id is None or node_id in self.nodes

    def collapsed_label(self, acting_node_id) -> str:
        return self.topology.collapsed_label_for(self.viewer_id, acting_node_id)
