"""Board reads + ball writes (M1 board, M2 automations, M6 perspective).

- The board read projects EVERYTHING through the viewer's PerspectiveLens
  (plan §4.3 — one rep() at the API boundary): the roster and
  visible_player_ids are the viewer's visible set, action_to/prev_action_to/
  author pass through rep() per ball (in bulk — the lens memoizes, no
  N+1), and the payload carries the viewer's court strip + side rows.
  Ordering is the manual `position` (plan §7), tie-broken deterministically.
- write_ball_patch applies the status<->review FIELD coupling (constants
  .STATUS_REVIEW_COUPLING, per court config), then the M2 touch automations
  (services.touches), guarded by the M6 write-side rules (§4.3b): explicit
  action_to targets must be visible to the actor, and "Ready for Review" is
  refused when the ball's author (the auto-spike target) is invisible.
- All vocab validation reads app.constants; nothing re-types a value.

Errors raise BoardError carrying the structured shape ({error_code, summary,
detail}) the routers serialize — same error language as the rest of the app.
"""
from __future__ import annotations

import uuid

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

from .. import constants
from ..models import Ball, Court, Match, Player, Touch
from . import notifications as notifications_service
from . import projection as projection_service
from .perspective import MatchTopology


class BoardError(Exception):
    """Structured service failure; router turns it into HTTP JSON."""

    def __init__(self, status_code: int, error_code: str, summary: str, detail: str = ""):
        super().__init__(summary)
        self.status_code = status_code
        self.error_code = error_code
        self.summary = summary
        self.detail = detail


# ------------------------------------------------------------------ reads


def get_match_by_slug(db: Session, slug: str) -> Match:
    match = db.execute(select(Match).where(Match.slug == slug)).scalar_one_or_none()
    if match is None:
        raise BoardError(404, "MATCH_NOT_FOUND", f"No match with slug {slug!r}")
    return match


def board_payload(db: Session, slug: str, viewer: Player | None = None) -> dict:
    """{match, sides, strip, players, visible_player_ids, courts (each with
    its balls ordered by position)} — the whole board, projected for ONE
    viewer (plan §4.3). `viewer` None = a signed-in user with no Player row
    -> the outside-observer default-deny view (services.perspective).

    Projection is bulk: topology loads once, the lens memoizes rep(), and
    every ball serializes to a plain dict here (never a mutated ORM row) —
    the board stays two ball queries + one roster query regardless of size.
    """
    match = get_match_by_slug(db, slug)
    lens = projection_service.build_lens(db, match.id, viewer)

    # The roster the board renders chips/avatars/autocomplete from: exactly
    # the viewer's visible players (§4.3 — Erin doesn't know Zeina exists).
    # Every id in any projected field below is in this list by construction.
    players = projection_service.visible_players_payload(db, lens)

    side_ids = projection_service.visible_sides(lens)
    sides = [
        {"id": sid, "name": lens.topology.side_name[sid], "position": pos}
        for pos, sid in sorted(
            (lens.topology.side_position.get(sid, 0), sid) for sid in side_ids
        )
    ]

    courts = (
        db.execute(
            select(Court).where(Court.match_id == match.id).order_by(Court.position, Court.name)
        )
        .scalars()
        .all()
    )
    court_ids = [c.id for c in courts]
    config_key_by_court = {c.id: c.config_key for c in courts}
    balls_by_court: dict = {cid: [] for cid in court_ids}
    if court_ids:
        balls = (
            db.execute(
                select(Ball)
                .where(Ball.court_id.in_(court_ids))
                .order_by(Ball.position, Ball.created_at, Ball.id)
            )
            .scalars()
            .all()
        )
        # Aging (plan §3.3) counts from the LATEST TOUCH, not creation — one
        # grouped query, stamped onto each ball for the wire (BallOut).
        last_touch = dict(
            db.execute(
                select(Touch.ball_id, func.max(Touch.created_at))
                .join(Ball, Touch.ball_id == Ball.id)
                .where(Ball.court_id.in_(court_ids))
                .group_by(Touch.ball_id)
            ).all()
        )
        for ball in balls:
            ball.last_touch_at = last_touch.get(ball.id, ball.created_at)
            balls_by_court[ball.court_id].append(
                projection_service.project_ball_dict(
                    lens, ball, config_key_by_court[ball.court_id]
                )
            )

    return {
        "match": match,
        "sides": sides,
        "strip": projection_service.strip_bands(lens),
        "players": players,
        "visible_player_ids": sorted(lens.visible, key=str),
        "courts": [
            {
                "id": c.id,
                "config_key": c.config_key,
                "name": c.name,
                "position": c.position,
                "config": c.config,
                "balls": balls_by_court.get(c.id, []),
            }
            for c in courts
        ],
    }


# ----------------------------------------------------------------- writes


def _validate_vocab(court: Court, data: dict) -> None:
    """Reject values outside the court's vocabularies (constants is the
    authority; the DB columns are plain text)."""
    cfg = constants.court_config(court.config_key) or {}
    if "status" in data:
        allowed = constants.status_values_for(court.config_key)
        if allowed and data["status"] not in allowed:
            raise BoardError(
                422,
                "INVALID_STATUS",
                f"{data['status']!r} is not a valid status for a {court.config_key} court",
                f"Valid values: {', '.join(allowed)}",
            )
    if data.get("review_state") is not None:
        if cfg.get("review_vocab") is None:
            raise BoardError(
                422,
                "REVIEW_STATE_NOT_ON_COURT",
                f"A {court.config_key} court has no review state column",
            )
        if data["review_state"] not in constants.SIGNOFF_OPTIONS:
            raise BoardError(
                422,
                "INVALID_REVIEW_STATE",
                f"{data['review_state']!r} is not a valid review state",
                f"Valid values: {', '.join(constants.SIGNOFF_OPTIONS)}",
            )
    if data.get("category") is not None and data["category"] not in constants.NOTE_CATEGORIES:
        raise BoardError(
            422,
            "INVALID_CATEGORY",
            f"{data['category']!r} is not a valid category",
            f"Valid values: {', '.join(constants.NOTE_CATEGORIES)}",
        )


def _validate_perspective_writes(
    db: Session, court: Court, ball: Ball, data: dict, actor: Player | None
) -> None:
    """The §4.3b write-side backstop (M6): a patch may only move the ball
    toward people the ACTOR can see.

    - Explicit action_to targets must be visible to the actor (the UI only
      offers visible players; this stops a crafted request).
    - "Ready for Review" is refused when the court auto-spikes and the
      ball's AUTHOR (the spike target) is invisible to the actor — Rahman
      passes to Adi instead of spiking across a net he can't see (the
      board read exposes the same rule as BallOut.can_spike, so honest
      clients never even offer the option).

    actor None = a signed-in BW user with no Player row. They can reach
    here only in dev-bypass setups; there is no position to validate
    against, so the vocab/authorship rules remain the only guards.
    """
    if actor is None:
        return
    needs_lens = "action_to" in data or data.get("status") == "Ready for Review"
    if not needs_lens:
        return
    lens = projection_service.build_lens(db, court.match_id, actor)
    if "action_to" in data:
        projection_service.ensure_targets_visible(lens, data["action_to"], BoardError)
    if data.get("status") == "Ready for Review":
        cfg = constants.court_config(court.config_key) or {}
        auto = (cfg.get("automations") or {}).get("auto_spike_on_ready")
        if auto and ball.author_id is not None and not lens.player_visible(ball.author_id):
            raise BoardError(
                422,
                "AUTOMATION_TARGET_NOT_VISIBLE",
                "Ready for Review would spike the ball to someone outside your court.",
                "Pass the ball to your lead instead — they can send it over the net.",
            )


def patch_ball(
    db: Session, ball_id: uuid.UUID, data: dict, actor: Player | None = None
) -> tuple[Ball, dict]:
    """Apply a partial update (`data` = only the explicitly-sent fields),
    then the status<->review field coupling for the counterpart field, then
    the M2 rally automations (auto-spike / rally memory / resolve / reopen /
    Informational->FYI) which move the ball and record touches.

    An explicit value always wins over automation: the coupling fills in
    review_state only when the patch changed status WITHOUT also setting
    review_state (and vice versa). Absent map keys mean "no rule" — e.g.
    setting review_state to "ready for review" by hand moves no status.

    Returns (ball, extras) — extras may carry {'ace': True} for the
    one-touch put-away flourish.
    """
    from . import touches as touch_service  # local import avoids cycle

    ball = db.get(Ball, ball_id)
    if ball is None:
        raise BoardError(404, "BALL_NOT_FOUND", f"No ball {ball_id}")
    court = db.get(Court, ball.court_id)

    _validate_vocab(court, data)
    _validate_perspective_writes(db, court, ball, data, actor)

    old_status = ball.status
    old_action_to = list(ball.action_to or [])

    for field, value in data.items():
        setattr(ball, field, value)

    # M5 hook: an explicit action_to patch notifies newly-added assignees
    # only (array diff vs before this patch), excluding the actor — same
    # transaction as this ball update (plan §6.4 / services.notifications).
    if "action_to" in data:
        notifications_service.notify_action_to_changed(
            db, ball, actor, old_action_to, ball.action_to
        )

    automations = (constants.court_config(court.config_key) or {}).get("automations", {})
    if "status" in data and "review_state" not in data:
        coupled = automations.get("status_to_review", {}).get(data["status"])
        if coupled is not None:
            ball.review_state = coupled
    elif "review_state" in data and "status" not in data:
        coupled = automations.get("review_to_status", {}).get(data["review_state"])
        if coupled is not None:
            ball.status = coupled

    extras = touch_service.apply_status_automations(
        db, ball, court, old_status, data, actor
    )

    db.commit()
    db.refresh(ball)
    touch_service.publish_patch(ball, court, extras)
    return ball, extras


def create_ball(
    db: Session,
    slug: str,
    data: dict,
    author: Player | None,
) -> Ball:
    """Create a ball on one of the match's courts. Status/review_state/
    category default from the court config; position appends at the end.
    No serve touch yet — touches are M2."""
    match = get_match_by_slug(db, slug)
    court = db.get(Court, data["court_id"])
    if court is None or court.match_id != match.id:
        raise BoardError(
            422,
            "COURT_NOT_IN_MATCH",
            f"Court {data['court_id']} does not belong to match {slug!r}",
        )

    cfg = constants.court_config(court.config_key) or {}
    defaults = cfg.get("defaults", {})

    _validate_vocab(court, data)

    # §4.3b: serve targets are validated like pass targets — the AUTHOR can
    # only serve toward players they can see (a sub-court serve "over the
    # net" targets the boundary lead, which is a visible player, so the
    # fleshout flow sails through). Topology loads once; autopass below
    # reuses it. Author None (no Player row yet) has nothing to validate
    # against — mirrors _validate_perspective_writes.
    topology = None
    if author is not None and data.get("action_to"):
        lens = projection_service.build_lens(db, match.id, author)
        topology = lens.topology
        projection_service.ensure_targets_visible(lens, data["action_to"], BoardError)

    max_position = (
        db.execute(select(Ball.position).where(Ball.court_id == court.id).order_by(Ball.position.desc()))
        .scalars()
        .first()
    )
    next_position = 0 if max_position is None else max_position + 1

    ball = Ball(
        court_id=court.id,
        title=data["title"],
        body=data.get("body"),
        status=defaults.get("status", constants.DEFAULT_PAGE_STATUS),
        review_state=defaults.get("review_state"),
        category=data.get("category") or defaults.get("category"),
        tags=data.get("tags") or [],
        url_staging=data.get("url_staging"),
        url_live=data.get("url_live"),
        action_to=data.get("action_to") or [],
        author_id=author.id if author else None,
        position=next_position,
    )
    db.add(ball)
    db.flush()

    # Every rally opens with a serve (M2): the creation IS the first touch.
    from . import autopass as autopass_service  # local imports avoid cycles
    from . import touches as touch_service

    serve_touch = touch_service.record_touch(
        db, ball, "serve", author, ball.action_to or [], court=court
    )

    # §4.4: a receiver's autopass rule carries a landed serve one hop
    # onward — exactly the fleshout "over the net + Adi's rule" compose.
    auto_touches: list = []
    if ball.action_to:
        if topology is None:
            topology = MatchTopology(db, match.id)
        auto_touches = autopass_service.apply_autopass(
            db, ball, court, serve_touch, topology
        )

    # M5: the serve IS the first "ball's coming to you" — initial assignees
    # get an action_to notification (diff vs empty; the server excludes the
    # author). Runs AFTER autopass so only the FINAL holder is pinged, not
    # an auto-passed intermediate. The review app's INSERT trigger covered
    # this; the M5 hook list only named the patch paths, so it lives here.
    if ball.action_to:
        notifications_service.notify_action_to_changed(
            db, ball, author, [], ball.action_to
        )

    # Announcements are never expected back (Informational -> FYI Only).
    automations = cfg.get("automations", {})
    fyi = automations.get("informational_sets_status")
    if ball.category == "Informational" and fyi:
        ball.status = fyi

    db.commit()
    db.refresh(ball)
    touch_service.publish_created(ball, court, serve_touch, auto_touches)
    return ball
