#!/usr/bin/env python3
"""Canonical markup for the four components that were authored twice.

WHY THIS FILE EXISTS
--------------------
step-tabs, rating-stars, message-thread and alert-toast were built independently
by the page generators and by make-styleguide.py on 2026-08-06. Neither authoring
was wrong; they simply never met, and the styleguide carried five [ RECONCILE ]
notes saying so.

Reconciled 2026-08-06 by making both sides import from here, exactly the way
listing_data.py already stops the search grid and the detail pages disagreeing
about a price. A component can no longer drift, because there is only one copy.

    make-bid-page.py         step_tabs, alert_toast
    make-provider-pages.py   step_tabs, rating_stars_display, rating_stars_input
    make-buyer-dashboards.py message_bubble
    make-styleguide.py       all of the above — the demos call these functions,
                             so the styleguide cannot document markup the
                             prototype does not emit.

WHICH AUTHORING WON, AND WHY
----------------------------
Decided per component on the merits, not by a blanket rule:

* step-tabs      -> the underline-tab treatment from provider-onboarding.html.
                    Two of the three authorings already used it, and the pill
                    treatment could not express a wizard of unknown length
                    without wrapping mid-row. Kept the check glyph and the
                    sr-only state text (provider had both, bid.html had
                    neither). Made state-driven rather than baked in, so the
                    same markup serves bid.html's clickable wizard and
                    provider-onboarding's server-rendered one.
* rating-stars   -> provider-rating.html. The styleguide demo rendered five
                    full stars for a 4.6 average; provider renders round(avg)
                    full plus the remainder hollow. That is a real defect, not
                    a style preference.
* message-thread -> dashboard-buyer-messages.html. Its timestamp sits under the
                    bubble on the page ground (text-ink-soft on paper) instead
                    of inside a navy bubble at paper/75, which is the more
                    legible of the two. bid.html's "message-thread (compose
                    only)" is a subset of this component, not a rival — it
                    renders the composer with no transcript, so it is left
                    alone.
* alert-toast    -> the styleguide's four-variant treatment, widened. bid.html
                    had two one-off panels and only the error variant; the
                    styleguide had the full success/warning/error/info map tied
                    to the canonical status colours. Added `dismissible` and a
                    `role` override so bid.html's persistent tip and its inline
                    validation error both express as this component instead of
                    as bespoke panels.

PROPS vs SLOTS (this is what Drupal Canvas enforces)
--------------------------------------------------
Canvas types every prop with JSON Schema, and anything that is *markup* has to be
a slot instead. Mapping for the four here:

    step-tabs       props: steps[] {label, state}, label, scroll   slots: none
    rating-stars    props: avg, count, threshold, show             slots: none
    message-thread  props: participants                            slots: messages
    alert-toast     props: variant, role, dismissible, headline    slots: body

`message_bubble(body=...)` and `alert_toast(body=...)` take rendered markup, so on
the Drupal side those parameters are SLOTS, not props — passing HTML through a
prop is what Canvas rejects. They stay parameters here because a static prototype
has no slot mechanism; components.json records the distinction for the port.

RULES
-----
Tokens only, never raw hex. Colour is never the only signal — every state also
changes a glyph, a label, or an sr-only string. Callers own layout (grid, width,
placement); this module owns the component's internals.
"""

# --------------------------------------------------------------------------
# step-tabs — props: steps[] (label, state: done/active/todo)
# --------------------------------------------------------------------------
# State lives in ONE place: data-step-state on the <a>. Everything else —
# underline colour, chip fill, which glyph shows, which sr-only string reads —
# derives from it through group-data variants. That is what lets bid.html's
# script re-label the whole row by rewriting a single attribute, while
# provider-onboarding.html ships the same markup with the states baked in.

_STEP_LINK = (
    "group flex items-center gap-2.5 whitespace-nowrap border-b-[3px] py-4 font-head text-[16px] "
    "border-transparent text-ink-soft hover:text-blue "
    "data-[step-state=done]:text-ink "
    "data-[step-state=active]:border-blue data-[step-state=active]:font-bold data-[step-state=active]:text-blue"
)

_STEP_CHIP = (
    "flex size-7 shrink-0 items-center justify-center rounded-full border border-rule text-[13px] font-bold text-ink-soft "
    "group-data-[step-state=done]:border-status-active group-data-[step-state=done]:bg-status-active group-data-[step-state=done]:text-paper "
    "group-data-[step-state=active]:border-navy group-data-[step-state=active]:bg-navy group-data-[step-state=active]:text-paper"
)


def step_tabs(steps, label="Progress", href=lambda i: f"#step-{i}", scroll=True):
    """Render the step-tabs component.

    steps  — [(label, state)] where state is done | active | todo
    label  — accessible name for the list
    href   — callable(index) -> fragment; steps are anchors so a wizard remains
             navigable without JavaScript
    scroll — overflow-x-auto (default). Chevrons and pills cannot wrap without
             colliding, so a row of unknown length scrolls rather than wraps.
    """
    items = ""
    for i, (text, state) in enumerate(steps, 1):
        assert state in ("done", "active", "todo"), f"bad step state: {state}"
        cur = ' aria-current="step"' if state == "active" else ""
        items += f"""
    <li><a href="{href(i)}" data-step data-step-state="{state}"{cur} class="{_STEP_LINK}">
      <span data-step-marker aria-hidden="true" class="{_STEP_CHIP}"><span class="group-data-[step-state=done]:hidden">{i}</span><span class="hidden group-data-[step-state=done]:inline">&#10003;</span></span>
      {text}
      <span class="sr-only"><span class="hidden group-data-[step-state=done]:inline"> (complete)</span><span class="hidden group-data-[step-state=active]:inline"> (current step)</span><span class="hidden group-data-[step-state=todo]:inline"> (not started)</span></span>
    </a></li>"""
    overflow = " overflow-x-auto" if scroll else " flex-wrap"
    return f"""
<!-- component: step-tabs — props: steps[] (label, state: done/active/todo) -->
<ol class="step-tabs flex gap-7{overflow} max-[560px]:gap-4" aria-label="{label}">{items}
</ol>"""


# --------------------------------------------------------------------------
# rating-stars — props: avg, count, show
# --------------------------------------------------------------------------
# DR-8: the aggregate is WITHHELD below five reviews. That is a display rule,
# not a data rule — the average is still computed and stored, it is only kept
# off the page. Any surface that sorts or filters by rating has to honour the
# same threshold, or the ordering leaks the score the badge is refusing to show.

DR8_MIN_REVIEWS = 5

WORDS = "Poor|Fair|Good|Very Good|Excellent"


def rating_stars_display(avg, count, threshold=DR8_MIN_REVIEWS, badge=True):
    """rating-stars in DISPLAY mode. Returns (html, published: bool).

    Glyphs are proportional: round(avg) filled, the remainder hollow. The glyph
    row is aria-hidden — the accessible reading is the number and the count,
    never a row of stars.
    """
    if count >= threshold:
        filled = int(round(avg))
        html = (
            f'<span class="font-head text-[22px] font-bold text-navy">{avg}</span>'
            f' <span aria-hidden="true" class="text-[20px] text-status-highlight">{"&#9733;" * filled}'
            f'<span class="text-rule">{"&#9734;" * (5 - filled)}</span></span>'
            f' <span class="text-[15px] text-ink-soft">{count} reviews</span>'
            f' <span class="sr-only">Rated {avg} out of 5 from {count} reviews.</span>'
        )
        if badge:
            html += (' <span class="rounded-sm bg-status-active/15 px-2 py-1 text-[12px] '
                     'font-bold text-status-active">Rating published</span>')
        return html, True

    html = (
        f'<span class="text-[16px] font-bold text-ink-soft">Not enough reviews yet</span>'
        f' <span class="text-[15px] text-ink-soft">{count} of {threshold} reviews collected</span>'
        f' <span class="sr-only">Rating withheld — {count} of {threshold} reviews collected.</span>'
    )
    if badge:
        html += (' <span class="rounded-sm bg-status-pending/15 px-2 py-1 text-[12px] '
                 'font-bold text-status-pending">Rating withheld</span>')
    return html, False


def dr8_comment(published, threshold=DR8_MIN_REVIEWS):
    """The HTML comment that records which side of DR-8 a block fell on."""
    if published:
        return (f"<!-- DR-8: this service line has {threshold} or more reviews, "
                "so the aggregate IS shown. -->")
    return (f"<!-- DR-8: fewer than {threshold} reviews, so the aggregate is WITHHELD. "
            "The reviews are still stored and counted — only the score is hidden. -->")


def rating_stars_input(name, glyph="&#9733;", words=WORDS, size="30px"):
    """NOTE ON IDS: `name` is the instance key — every id here is derived from it,
    never literal, so the component can appear twice on one page without colliding.
    In Drupal this becomes Html::getUniqueId() or Twig's `random()`; the contract
    is the same either way: no component may hardcode an id.
    """
    """rating-stars in INPUT mode.

    Radios carry the value; the labels are the stars. Real form controls, so it
    is keyboard-operable and submits a value — a CSS-only star row is neither.
    Each label carries its own sr-only "N of 5 — Word", so the meaning does not
    depend on seeing how many glyphs are lit.
    """
    out = ""
    for n in range(1, 6):
        cid = f"{name}-{n}"
        word = words.split("|")[n - 1]
        out += (
            f'<input type="radio" id="{cid}" name="{name}" value="{n}" class="sr-only">'
            f'<label for="{cid}" data-star="{n}" '
            f'class="cursor-pointer px-0.5 text-[{size}] leading-none text-rule hover:text-status-highlight">'
            f'{glyph}<span class="sr-only">{n} of 5 — {word}</span></label>'
        )
    return f'<span class="rating-stars inline-flex" data-stars="{name}">{out}</span>'


# --------------------------------------------------------------------------
# message-thread — props: messages[], participants
# --------------------------------------------------------------------------
# Sides are distinguished by THREE signals, not one: alignment, ground
# (navy vs shell), and the named author on every timestamp line. Alignment alone
# fails on a narrow viewport and means nothing to a screen reader, which is why
# the author is repeated on each bubble rather than implied by the column.

def message_bubble(mine, author, time, body):
    """One bubble in a thread. `mine` right-aligns it on navy."""
    if mine:
        return f"""
            <li class="flex flex-col items-end">
              <div class="max-w-[78%] rounded-sm rounded-br-none bg-navy px-4 py-3 text-[15px] leading-relaxed text-paper">{body}</div>
              <p class="mt-1 text-[13px] text-ink-soft"><span class="font-bold">{author}</span> &middot; {time}</p>
            </li>"""
    return f"""
            <li class="flex flex-col items-start">
              <div class="max-w-[78%] rounded-sm rounded-bl-none border border-rule bg-shell px-4 py-3 text-[15px] leading-relaxed text-ink">{body}</div>
              <p class="mt-1 text-[13px] text-ink-soft"><span class="font-bold">{author}</span> &middot; {time}</p>
            </li>"""


def message_transcript(messages):
    """messages — [(mine, author, time, body)]"""
    return ('\n          <!-- component: message-thread — props: messages[], participants -->'
            '\n          <ol class="message-thread space-y-4">'
            + "".join(message_bubble(*m) for m in messages)
            + '\n          </ol>')


# --------------------------------------------------------------------------
# alert-toast — props: message, variant
# --------------------------------------------------------------------------
# Variants map onto the canonical status colours — success=status-active,
# warning=status-pending, error=status-alert, info=blue — so a toast and a badge
# describing the same thing agree on colour. Each toast carries an icon and a
# headline as well as the tint: colour is never the only signal.
#
# Live-region roles differ by variant. success, warning and info use
# role="status" (polite); error uses role="alert" (assertive), which interrupts
# a screen reader mid-sentence. Callers may override — a persistent tip is not a
# live region at all and passes role="note".

TOAST_TOKENS = {
    "success": ("status-active", "status", '<path d="M4 12.5l5.5 5.5L20 6"></path>'),
    "warning": ("status-pending", "status", '<path d="M12 4l9 16H3z"></path><path d="M12 10v4M12 17.2v.1"></path>'),
    "error":   ("status-alert", "alert", '<circle cx="12" cy="12" r="9"></circle><path d="M12 7v6M12 16.2v.1"></path>'),
    "info":    ("blue", "status", '<circle cx="12" cy="12" r="9"></circle><path d="M12 11v6M12 7.8v.1"></path>'),
}


def alert_toast(variant, headline, body="", role=None, dismissible=True,
                extra_attrs="", extra_class=""):
    """Render the alert-toast component.

    variant     — success | warning | error | info
    role        — defaults per variant; pass "note" for a persistent callout
    dismissible — the × button. False for inline validation and for tips, which
                  must not be dismissable away from the field they explain.
    extra_attrs — e.g. 'hidden data-agent-conflict' for a validation slot
    """
    tok, default_role, path = TOAST_TOKENS[variant]
    role = role or default_role
    dismiss = ""
    if dismissible:
        dismiss = (
            f'\n              <button type="button" class="-m-1 flex size-8 shrink-0 items-center justify-center '
            f'rounded-full text-ink-soft hover:bg-shell hover:text-ink" aria-label="Dismiss: {headline}">'
            f'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" '
            f'aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"></path></svg></button>'
        )
    body_html = f'\n                <p class="mt-0.5 text-[14px] leading-relaxed text-ink-soft">{body}</p>' if body else ""
    cls = f"alert-toast flex items-start gap-3 rounded-sm border border-rule border-l-4 border-l-{tok} bg-paper p-4 shadow-sm"
    if extra_class:
        cls += " " + extra_class
    attrs = (" " + extra_attrs) if extra_attrs else ""
    return f"""
            <!-- component: alert-toast (variant: {variant}) -->
            <div role="{role}" data-toast-variant="{variant}" class="{cls}"{attrs}alert-toast >
              <span class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-{tok}/15 text-{tok}" aria-hidden="true"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">{path}</svg></span>
              <div class="min-w-0 flex-1">
                <p class="text-[15px] font-bold text-ink">{headline}</p>{body_html}
              </div>{dismiss}
            </div>"""
