"""Shared resolver for the ``countdown`` scene strategy.

A countdown is the "one opening, then N beats of the same kind, counting down"
shape: Top-N lists, "5 个技巧", "7 个常见错误". It sits on the same axis as
``arc`` / ``single`` / ``fixed`` because it is a *shape*, not a subject — which
is the whole reason it is not called "ranking" and holds no GitHub-specific
knowledge.

None of the existing strategies could express it: ``arc`` clamps to at least 3
scenes, always appends a ``cta``, and numbers its beats upward; ``fixed`` needs a
static scene list and so cannot take a count at all.

─── Why this module is shared rather than copied ──────────────────────────────
Two skills need the same answer from the same declaration:

  * ``gen-script``   — to lay out the scenes and write the count into each payload
  * ``render-video`` — to validate a plan (scene count, countdown completeness)
    before any asset is paid for

Copying the resolution into both is how the previous iteration of this feature
went wrong in the other direction (a Python port of the template's copy rules
living next to the real ones). ``skills/template-registry/scripts/`` is the
sanctioned home for cross-skill Python — see AGENTS.md.

─── What is declared where ───────────────────────────────────────────────────
The strategy carries the shape; the template carries every concrete value:

    "capabilities": {
      "sceneStrategy": "countdown",
      "countdown": {
        "countProp": "topN",            // where the count goes in customPayload
        "itemProp": "item",             // optional, default "item"
        "indexProp": "rank",            // optional, default "rank"
        "legacyCountProps": ["totalItems"],
        "openingSec": 2.5               // optional, default DEFAULT_OPENING_SEC
      }
    }

Bounds are deliberately **not** repeated here: they are read from
``customPayloadSchema[countProp]``'s ``minimum`` / ``maximum`` / ``default``,
which every template already has to author for its own payload contract. Having
one number in two places is how they drift.

Declaring the prop *names* is what keeps this module honest. They used to be
hardcoded, which meant a second countdown template naming its count
``itemCount`` would silently lose validation entirely: nothing would be found,
the count would fall back to "however many cards are present", and the
"scene count matches the count" check would compare that number to itself.
"""

from __future__ import annotations

#: Opening budget when the template declares none. A generic "an opening is a
#: beat, not a chapter" value — **not** any one template's measurement. It used
#: to be 3 hardcoded in gen_script, taken from github-repo-rank's reference film
#: (2.43s), which put a single template's fact in the CLI.
DEFAULT_OPENING_SEC = 3


class CountdownError(ValueError):
    """Raised for an unusable countdown declaration.

    A subclass so callers can keep treating it as the ValueError they already
    handle, while a reader can still see that an authoring mistake and a bad
    ``--item-count`` are two different things.
    """


def resolve_countdown_spec(template: dict | None) -> dict | None:
    """Resolve a template's countdown declaration, or None if it is not one.

    Raises CountdownError when ``sceneStrategy=countdown`` is declared but the
    rest of the contract is missing. Failing loudly here is the point: every
    downstream check reads these names, so an incomplete declaration must not
    degrade into "no countdown rules apply".
    """
    caps = (template or {}).get("capabilities")
    caps = caps if isinstance(caps, dict) else {}
    if caps.get("sceneStrategy") != "countdown":
        return None

    template_id = (template or {}).get("templateId", "?")
    cfg = caps.get("countdown")
    cfg = cfg if isinstance(cfg, dict) else {}

    count_prop = cfg.get("countProp")
    if not isinstance(count_prop, str) or not count_prop.strip():
        raise CountdownError(
            f"Template '{template_id}' declares capabilities.sceneStrategy=countdown but no "
            "capabilities.countdown.countProp. Declare which customPayload field carries the "
            "item count (github-repo-rank uses \"topN\")."
        )
    count_prop = count_prop.strip()

    schema = (template or {}).get("customPayloadSchema")
    bounds = schema.get(count_prop) if isinstance(schema, dict) else None
    if not isinstance(bounds, dict):
        raise CountdownError(
            f"Template '{template_id}' declares countdown.countProp='{count_prop}' but "
            f"customPayloadSchema.{count_prop} is missing. The count's bounds and default are "
            "read from there, so that one number is not authored twice."
        )

    maximum, default = bounds.get("maximum"), bounds.get("default")
    minimum = bounds.get("minimum")
    minimum = minimum if type(minimum) is int else 1
    if type(maximum) is not int or type(default) is not int:
        raise CountdownError(
            f"Template '{template_id}': customPayloadSchema.{count_prop} must declare integer "
            "'maximum' and 'default'. Without a maximum the count is unbounded; without a "
            "default there is no answer for a caller that omits the count."
        )
    if not minimum <= default <= maximum:
        raise CountdownError(
            f"Template '{template_id}': customPayloadSchema.{count_prop}.default ({default}) is "
            f"outside its own minimum/maximum ({minimum}–{maximum})."
        )

    legacy = cfg.get("legacyCountProps")
    legacy = [p for p in legacy if isinstance(p, str) and p.strip()] if isinstance(legacy, list) else []

    opening_sec = cfg.get("openingSec")
    if not (type(opening_sec) in (int, float) and opening_sec > 0):
        opening_sec = DEFAULT_OPENING_SEC

    return {
        "template_id": template_id,
        "count_prop": count_prop,
        "legacy_count_props": legacy,
        "item_prop": (cfg.get("itemProp") or "item").strip(),
        "index_prop": (cfg.get("indexProp") or "rank").strip(),
        "minimum": minimum,
        "maximum": maximum,
        "default": default,
        "opening_sec": float(opening_sec),
    }


def resolve_count(spec: dict, requested: int | None, scene_count: int | None) -> int:
    """Validate a caller-supplied item count against the template's own bounds.

    ``requested`` is the number of content beats the caller asked for — the same
    number the user picked. It is deliberately *not* derived from ``scene_count``:
    a caller passing the user's 5 as a total scene count would produce 4 cards
    with a self-consistent ``count=4`` written everywhere, which no validator can
    catch and the user never sees. So the two are cross-checked instead.
    """
    count = spec["default"] if requested is None else requested
    if type(count) is not int or not spec["minimum"] <= count <= spec["maximum"]:
        raise ValueError(
            f"item count must be an integer from {spec['minimum']} to {spec['maximum']} "
            f"(declared by template '{spec['template_id']}')"
        )
    if scene_count is not None and scene_count != count + 1:
        raise ValueError(
            f"--scenes ({scene_count}) conflicts with the item count ({count}): a countdown is "
            f"one opening plus N beats, so --scenes would have to be {count + 1}. Pass only one."
        )
    return count


def opening_sec(spec: dict, count: int, duration: float) -> float:
    """Seconds for the single opening scene.

    ``openingSec`` is an upper bound, not a fixed value: on a short video it
    yields to an even split so a long opening budget cannot starve the beats.

    Worth knowing how provisional this is — ``render_video``'s
    ``adjust_timeline_to_audio`` overwrites every scene's duration with the real
    narration length once TTS exists. This number shapes the script the user
    reviews and the duration/cost estimate, not the finished cut.
    """
    return min(spec["opening_sec"], duration / (count + 1))


def scene_payload(spec: dict, count: int, index: int | None) -> dict:
    """The countdown part of one scene's customPayload, under declared names.

    ``index`` is None for the opening. For a beat only the index is filled:
    inventing the rest of the item would put fabricated data on screen, so the
    caller is expected to fill it with real content before assets are prepared.
    """
    payload: dict = {spec["count_prop"]: count}
    for prop in spec["legacy_count_props"]:
        payload[prop] = count
    if index is not None:
        payload[spec["item_prop"]] = {spec["index_prop"]: index}
    return payload
