"""The rally engine (M2): touches + the volleyball automations.

Touches are append-only; a ball's rally is its touches ORDER BY seq. The
review app's proven status automations (review-patterns-ref §2) become ball
MOVEMENT here:

- status -> "Ready for Review"   auto-SPIKE to the ball's author; current
                                 assignees saved to prev_action_to (rally
                                 memory).
- status -> "Revision Required"  return to prev_action_to (rally memory).
- status -> "Resolved"           put away: save assignees for reopen, clear
                                 action_to, stamp resolved_at. A rally with
                                 no touches beyond the serve = an ACE.
- Resolved -> anything else      REOPEN: restore prev_action_to, clear
                                 resolved_at.
- category -> "Informational"    status becomes "FYI Only" (an announcement,
                                 not a serve; no return expected).

Every state change publishes an SSE event (events.publish) so the live board
(M3) is already fed. Perspective projection of rally payloads happens at the
API boundary (services.projection over services.perspective — M6); this
module records and moves RAW truth only. The two M6 write-side rules that DO
live here (§4.3b is server-authoritative): pass/serve targets must be
visible to the acting player, and a landed assignment runs the receiver's
autopass rule (services.autopass, one hop).
"""
from __future__ import annotations

import datetime
import uuid as uuidlib

from sqlalchemy import func, select
from sqlalchemy.orm import Session

from .. import constants, events
from ..models import Ball, Court, Player, TeamMember, Touch
from . import autopass as autopass_service
from . import notifications as notifications_service
from . import projection as projection_service
from .perspective import MatchTopology, PerspectiveLens


# ------------------------------------------------------------------ helpers


def _next_seq(db: Session, ball_id) -> int:
    cur = db.execute(select(func.max(Touch.seq)).where(Touch.ball_id == ball_id)).scalar()
    return (cur or 0) + 1


def acting_node_id_for(db: Session, actor: Player | None, court: Court):
    """The team node the actor plays on in this match — the touch's
    visibility scope (plan §4.3). Public: services.conversation reuses this
    exact lookup for comments (same visibility-scope idiom), so a touch and
    a comment written in the same moment land in the same scope. Don't
    duplicate this query elsewhere — import and call it."""
    if actor is None:
        return None
    match_id = db.get(Court, court.id).match_id if court else None
    if match_id is None:
        return None
    tm = db.execute(
        select(TeamMember.node_id).where(
            TeamMember.match_id == match_id, TeamMember.player_id == actor.id
        )
    ).scalar_one_or_none()
    return tm


def record_touch(
    db: Session,
    ball: Ball,
    kind: str,
    actor: Player | None,
    to_players: list | None = None,
    via_rule_id=None,
    court: Court | None = None,
) -> Touch:
    court = court or db.get(Court, ball.court_id)
    touch = Touch(
        ball_id=ball.id,
        seq=_next_seq(db, ball.id),
        kind=kind,
        from_player=actor.id if actor else None,
        to_players=[_uuid(p) for p in (to_players or [])],
        acting_node_id=acting_node_id_for(db, actor, court),
        via_rule_id=via_rule_id,
    )
    db.add(touch)
    # Flush so a subsequent record_touch in the same transaction (autopass
    # chains, tests) sees this seq — _next_seq queries the DB.
    db.flush()
    return touch


def _uuid(v):
    return v if isinstance(v, uuidlib.UUID) else uuidlib.UUID(str(v))


def rally_for(db: Session, ball_id) -> list[Touch]:
    return (
        db.execute(select(Touch).where(Touch.ball_id == ball_id).order_by(Touch.seq))
        .scalars()
        .all()
    )


def _publish(kind: str, ball: Ball, court: Court, touch: Touch | None = None, **extra) -> None:
    evt = {
        "type": kind,
        "ball_id": str(ball.id),
        "court_id": str(court.id),
        # match_id lets the SSE boundary load the topology and project this
        # event PER SUBSCRIBER (services.projection.project_event) — the bus
        # itself stays dumb and broadcasts raw truth (app.events docstring).
        "match_id": str(court.match_id),
        "config_key": court.config_key,
        "status": ball.status,
        "review_state": ball.review_state,
        "category": ball.category,
        "action_to": [str(p) for p in (ball.action_to or [])],
        "resolved": ball.resolved_at is not None,
    }
    if touch is not None:
        evt["touch"] = {
            "kind": touch.kind,
            "from_player": str(touch.from_player) if touch.from_player else None,
            "to_players": [str(p) for p in (touch.to_players or [])],
            "auto": touch.via_rule_id is not None,
        }
    evt.update(extra)
    events.publish(evt)


# ------------------------------------------------------------------ actions


def publish_created(ball: Ball, court: Court, touch: Touch | None, auto_touches=None) -> None:
    """SSE for a freshly served ball (call after commit). Any autopass
    touches the serve triggered follow as their own events so the strip
    can animate both hops (serve arc, then the bolt)."""
    _publish("ball.created", ball, court, touch)
    for auto in auto_touches or []:
        _publish("ball.updated", ball, court, auto)


def pass_ball(db: Session, ball: Ball, actor: Player | None, to_players: list) -> Touch:
    """Move the ball to specific hands — the one explicit movement gesture.

    prev_action_to is deliberately NOT written here: it is the rally memory
    for the Ready-for-Review -> Revision-Required cycle (and reopen), exactly
    as the review app used it. A plain pass never disturbs it.

    M6 (§4.3b/§4.4): targets must be visible to the ACTOR (422
    TARGET_NOT_VISIBLE backstop — the UI only offers visible players), and
    a receiver's active autopass rule carries the ball one hop onward. The
    action_to notification goes to the FINAL holder only (the diff runs
    old -> post-autopass state, so an auto-passed intermediate is never
    pinged about a ball that already left their hands).
    """
    from .board import BoardError  # local import avoids a module cycle

    court = db.get(Court, ball.court_id)
    topology = MatchTopology(db, court.match_id)
    actor_lens = PerspectiveLens(topology, actor.id if actor else None)
    to_ids = [_uuid(p) for p in to_players]
    projection_service.ensure_targets_visible(actor_lens, to_ids, BoardError)

    old_action_to = list(ball.action_to or [])
    ball.action_to = to_ids
    touch = record_touch(db, ball, "pass", actor, to_players, court=court)
    auto_touches = autopass_service.apply_autopass(db, ball, court, touch, topology)
    # M5 hook, AFTER autopass so only the final holder is notified —
    # excluding the actor; same transaction as this ball update (plan §6.4).
    notifications_service.notify_action_to_changed(db, ball, actor, old_action_to, ball.action_to)
    db.commit()
    db.refresh(ball)
    _publish("ball.updated", ball, court, touch)
    for auto in auto_touches:
        _publish("ball.updated", ball, court, auto)
    return touch


def apply_status_automations(
    db: Session,
    ball: Ball,
    court: Court,
    old_status: str | None,
    data: dict,
    actor: Player | None,
) -> dict:
    """The volleyball consequences of a field patch. Called by patch_ball
    AFTER the field coupling, BEFORE commit. Returns extras for the response
    ({'ace': True} on a one-touch put-away)."""
    extras: dict = {}
    new_status = ball.status
    new_category = data.get("category")

    # Informational -> FYI Only (announcement, not a serve).
    automations = (constants.court_config(court.config_key) or {}).get("automations", {})
    fyi = automations.get("informational_sets_status")
    if new_category == "Informational" and fyi and "status" not in data:
        ball.status = new_status = fyi

    if "status" not in data and new_category != "Informational":
        return extras
    if old_status == new_status:
        return extras

    touch: Touch | None = None

    if new_status == "Ready for Review":
        # SPIKE back to the author; remember who had it (rally memory).
        # M6 (§4.3b): the caller (board.patch_ball) has already refused this
        # status when the author is invisible to the actor — by the time we
        # are here the spike target is a legitimate cross-net play.
        old_action_to = list(ball.action_to or [])
        ball.prev_action_to = ball.action_to or []
        ball.action_to = [ball.author_id] if ball.author_id else []
        touch = record_touch(db, ball, "spike", actor, ball.action_to, court=court)
        # §4.4: the spiked-to author may autopass onward (one hop).
        if ball.action_to:
            topology = MatchTopology(db, court.match_id)
            extras["auto_touches"] = autopass_service.apply_autopass(
                db, ball, court, touch, topology
            )
        # M5 hook, AFTER autopass: only the FINAL holder is pinged,
        # excluding the actor.
        notifications_service.notify_action_to_changed(db, ball, actor, old_action_to, ball.action_to)

    elif new_status == "Revision Required":
        # Return to whoever had it before (rally memory).
        old_action_to = list(ball.action_to or [])
        ball.action_to = ball.prev_action_to or []
        # M5 hook: notify the newly-restored assignees, excluding the actor.
        notifications_service.notify_action_to_changed(db, ball, actor, old_action_to, ball.action_to)
        touch = record_touch(db, ball, "pass", actor, ball.action_to, court=court)

    elif new_status in ("Resolved",) or new_status == "completed":
        # Put away. Save hands for reopen, clear, stamp.
        prior = _next_seq(db, ball.id) - 1  # touches so far
        if ball.action_to:
            ball.prev_action_to = ball.action_to
        ball.action_to = []
        ball.resolved_at = datetime.datetime.now(datetime.timezone.utc)
        touch = record_touch(db, ball, "resolve", actor, [], court=court)
        # M5 hook: flip .resolved on every notification this ball produced
        # (no new assignees on a clear, so no notify_action_to_changed here).
        notifications_service.propagate_resolved(db, ball, True)
        if prior <= 1:
            extras["ace"] = True

    elif old_status in ("Resolved", "completed") and new_status not in ("Resolved", "completed"):
        # REOPEN: the fix didn't take. Rally memory restores the hands.
        old_action_to = list(ball.action_to or [])
        ball.action_to = ball.prev_action_to or []
        ball.resolved_at = None
        # M5 hooks: notify the newly-restored assignees, excluding the
        # actor; un-resolve this ball's notifications (reopen flips back).
        notifications_service.notify_action_to_changed(db, ball, actor, old_action_to, ball.action_to)
        notifications_service.propagate_resolved(db, ball, False)
        touch = record_touch(db, ball, "reopen", actor, ball.action_to, court=court)

    if touch is not None:
        extras["touch"] = touch
    return extras


def publish_patch(ball: Ball, court: Court, extras: dict) -> None:
    """SSE for a patch (after commit). Kept separate so patch_ball controls
    transaction boundaries. Autopass touches (a spiked-to author's rule
    firing) follow the primary event as their own events."""
    _publish(
        "ball.updated",
        ball,
        court,
        extras.get("touch"),
        ace=bool(extras.get("ace")),
    )
    for auto in extras.get("auto_touches") or []:
        _publish("ball.updated", ball, court, auto)
