"""Status rollup — the project's lifecycle mapped onto the tool contract's six
frozen states. Pure function over a snapshot so it is trivially testable and
becomes the contract's status body verbatim when the caddie drop-in lands.

The mapping (goal §6; D6 as REVISED 2026-09-01 — see below):

  not_started      nothing is ready to show yet, and not presented
  needs_attention  PRESENTED with nothing on it to review — the one genuinely
                   anomalous state: the client has a link to an empty board
  in_progress      something is ready, project not yet presented (sent_at NULL)
  waiting_agency   presented, and any OPEN client-authored pin thread exists —
                   we owe a reply, whatever else is true
  done             presented, every decidable screen has a selected option, every
                   requires_approval step has a client approval, nothing open
  waiting_client   presented, none of the above — the ball is with the client

An empty instance must report not_started, never done (contract §2).

**Why D6 changed.** The original rule made needs_attention mean "selection is
complete but open client pins remain — conflicting signals". Walking a real
client journey showed that is not a conflict at all: a client picking a
direction AND leaving comments is the *designed* outcome of a round, so the
most alarming state in the set fired on the happy path. needs_attention now
means something genuinely anomalous — we presented a screen with nothing on it
— and open client feedback resolves to waiting_agency, which is what it is.

**"Ready" means the option has a mockup.** An option with no files is invisible
to the client (routers/projects.py filters it), so it cannot be reviewed or
chosen; counting it would let an unfinished board report a decidable screen.
"""

from dataclasses import dataclass, field

STATES = ("not_started", "in_progress", "waiting_client", "waiting_agency",
          "needs_attention", "done")


@dataclass(frozen=True)
class ScreenSnapshot:
    option_count: int
    selected: bool
    ready_count: int = 0   # options carrying a mockup — the client-visible ones


@dataclass(frozen=True)
class ProjectSnapshot:
    """What the rollup needs to know — assembled by services.projects from the DB.

    open_client_pins:  count of unresolved pin threads whose creator's effective
                       level on this project is the client side (member).
    open_agency_pins:  same, authored by the team (admin/owner).
    required_steps:    count of walkthrough steps with requires_approval on
                       options that belong to this project.
    approved_required_steps: of those, how many have >= 1 client approval.
    """
    screens: tuple[ScreenSnapshot, ...] = field(default_factory=tuple)
    sent: bool = False
    open_client_pins: int = 0
    open_agency_pins: int = 0
    required_steps: int = 0
    approved_required_steps: int = 0
    # The client pressed "Feedback complete" this round: the ball is with us
    # even if nothing else says so.
    feedback_complete: bool = False

    @property
    def option_total(self) -> int:
        return sum(s.option_count for s in self.screens)

    @property
    def ready_total(self) -> int:
        return sum(s.ready_count for s in self.screens)


def rollup(snap: ProjectSnapshot) -> str:
    if snap.ready_total == 0:
        # Presented a board with nothing on it: the client holds a link to an
        # empty page. Nothing else in the lifecycle is a mistake — this is.
        return "needs_attention" if snap.sent else "not_started"
    if not snap.sent:
        return "in_progress"
    # Open client feedback outranks everything else that is merely pending: it is
    # the one state with a person waiting on us.
    if snap.open_client_pins > 0:
        return "waiting_agency"
    # An empty screen SHELL does not count against the project: adding "About"
    # ahead of its upload is ordinary staging, and the client never sees it.
    decidable = [s for s in snap.screens if s.ready_count > 0]
    selection_complete = bool(decidable) and all(s.selected for s in decidable)
    approvals_complete = snap.approved_required_steps >= snap.required_steps
    if selection_complete and approvals_complete:
        return "done"
    if snap.feedback_complete:
        return "waiting_agency"
    return "waiting_client"


def status_body(instance_id: str, snap: ProjectSnapshot, seq: int,
                occurred_at: str, actors_waiting: list[str] | None = None) -> dict:
    """The contract §5 report body. Unused until the M2 drop-in posts it, but
    shaped now so the seam is real and tested."""
    state = rollup(snap)
    selected = sum(1 for s in snap.screens if s.selected and s.ready_count)
    with_options = sum(1 for s in snap.screens if s.ready_count)
    return {
        "assignment_id": None,  # filled by the drop-in from caddie_links
        "state": state,
        "counts": {"client_open": snap.open_client_pins,
                   "agency_open": snap.open_agency_pins},
        "summary": f"{selected} of {with_options} screens decided",
        # Whose move it is, by name. caddie's job is telling a client where
        # their project stands, and "waiting" without "on whom" is the half that
        # does not help.
        "actors_waiting": list(actors_waiting or []),
        "seq": seq,
        "occurred_at": occurred_at,
    }
