# PoC sketch -- apps/overlays/services/field_mapping.py
#
# Option A from SPEC_OVERLAY_PACKS.md follow-up: OverlayTemplate.field_mappings,
# a JSONField(default=list) on the SUBSCRIBER template, same shape/authoring
# posture as OverlayTemplate.control_fields (admin-authored, dot-path addressed,
# resolved by generic code that never interprets what a path *means*):
#
#   OverlayTemplate.field_mappings = [
#     {
#       "key": "team_b_games",
#       "source_template_id": "<uuid of 'Padel / Tennis Scoreboard'>",
#       "source_path": "score.games.1",
#       "target_path": "score.games.1",
#       "label": "Team B Games",
#     },
#     ...
#   ]
#
# NOT YET WIRED IN -- no migration, no call site beyond the sketch fan-out at the
# bottom. Open decisions flagged inline before this becomes a real Build task:
#   1. positional (`target_path`) vs named/labeled wire keys -- see the docstring
#      on fan_out_to_subscribers for why this matters for 3rd-party consumers.
#   2. where the fan-out actually gets called from (sketched against the existing
#      ProjectSessionViewSet.state action below, not yet moved into a service).
#   3. `key` (new) + ProjectSession.mapped_fields (new) -- how ONE connection
#      (one paired ProjectSession) selects a subset of a template's full
#      field_mappings catalog rather than always getting all of it. See the
#      "per-connection selection" section below, right after resolve_mapped_config.

from apps.overlays.choices import ProjectSessionSyncMode
from apps.overlays.models import OverlayTemplate, ProjectSession

_UNSET = object()


def _get_by_path(source: dict, path: str):
    """Dot-path get, same addressing convention control_fields' own `path` already
    uses ("score.games.1" -> source["score"]["games"][1]). Numeric segments index
    into a list; everything else is a dict key. Returns _UNSET (not None -- a
    mapped field could legitimately resolve to None/0/"") on any missing key,
    wrong type, or out-of-range index, so the caller can tell "absent" apart from
    "present but falsy" and skip it rather than writing a bogus null through."""
    current = source
    for segment in path.split('.'):
        if isinstance(current, list):
            if not segment.isdigit() or int(segment) >= len(current):
                return _UNSET
            current = current[int(segment)]
        elif isinstance(current, dict):
            if segment not in current:
                return _UNSET
            current = current[segment]
        else:
            return _UNSET
    return current


def _set_by_path(target: dict, path: str, value) -> None:
    """Dot-path set, building intermediate dicts/lists on demand. A numeric
    segment past the current list's length pads with None up to that index --
    field_mappings entries aren't guaranteed to arrive/resolve in index order
    (e.g. games.1 before games.0 if only Team B changed), and target_path's
    shape is declared by the mapping, not inferred from what's resolved so far."""
    segments = path.split('.')
    current = target
    for i, segment in enumerate(segments[:-1]):
        next_segment = segments[i + 1]
        is_list_index = next_segment.isdigit()
        if isinstance(current, dict):
            if segment not in current or not isinstance(current[segment], (dict, list)):
                current[segment] = [] if is_list_index else {}
            current = current[segment]
        elif isinstance(current, list):
            idx = int(segment)
            while len(current) <= idx:
                current.append(None)
            if not isinstance(current[idx], (dict, list)):
                current[idx] = [] if is_list_index else {}
            current = current[idx]

    last = segments[-1]
    if isinstance(current, list):
        idx = int(last)
        while len(current) <= idx:
            current.append(None)
        current[idx] = value
    else:
        current[last] = value


def _resolve_set_history(pairs) -> list:
    """The one aggregate/reshape mapping kind, not a plain 1:1 path copy --
    ScoreboardScoreState.setHistory (frontend-overlays/interfaces/
    scoreboard.interface.ts) is `[number, number][]`, e.g. [[6,4],[3,6]]: a
    positional pair-per-set, team identity implicit in tuple order, set
    identity implicit in list position. Reshapes into
    [{"set_number": 1, "team_a_games": 6, "team_b_games": 4}, ...] -- named,
    1-indexed, self-describing without the reader needing to already know
    "index 0 of each pair is team A" or "list position N is set N+1".

    One shared entry per set, both teams' numbers together -- the exact fix
    flagged over the earlier "sets_win" split-per-team-list sketch: a
    completed set is one fact (two scores, one set_number), so it's written
    once here, not as two independent per-team appends that could drift out
    of sync with each other."""
    if not isinstance(pairs, list):
        return []
    return [
        {'set_number': i + 1, 'team_a_games': pair[0], 'team_b_games': pair[1]}
        for i, pair in enumerate(pairs)
        if isinstance(pair, list) and len(pair) == 2
    ]


_RESHAPE_KINDS = {
    'set_history': _resolve_set_history,
}


def resolve_mapped_config(
    subscriber_template: OverlayTemplate, source_template_id, source_config: dict, selected_keys=None
) -> dict:
    """The actual cross-template contract: every field_mappings entry whose
    source_template_id matches the *live* paired session's own template (not just
    "any pack sibling" -- a mapping is declared per source template on purpose,
    since two scoreboard variants in the same pack could shape `score`
    differently) is walked off source_config and written into a fresh derived
    dict at its target_path.

    selected_keys: None (default) resolves every matching entry in the
    template's catalog, same as before this parameter existed. A set narrows
    that down to only the entries whose own `key` is a member -- this is the
    per-CONNECTION selection (ProjectSession.mapped_fields, see below), not a
    per-template one: the template can declare 20 possible fields, and two
    different sessions paired to the same source can each resolve a
    different subset of those 20 without the template itself changing.
    Unlike source_path/UNSET above, an empty selected_keys set is NOT the
    same as None here -- the caller (fan_out_to_subscribers) is responsible
    for turning "session declared no selection" into None before calling
    this, since {} legitimately means "resolve nothing" if a session were
    ever explicitly configured that way.

    Two ways a mapping entry can transform the raw value on its way to
    target_path, both optional and mutually exclusive (a mapping declares at
    most one):
    - `value_map`: a plain {"<raw string>": "<mapped value>"} dict for
      enum-shaped fields -- e.g. ScoreboardScoreState.server.team is a raw
      0/1 TeamIndex; {"0": "team_a", "1": "team_b"} turns it into the same
      named key the `teams` section itself uses, so a consumer never needs
      to know "0 means team A" out of band. Looked up by str(value) since
      JSON/dict keys from admin-authored field_mappings are always strings
      even when the live value is an int.
    - `kind`: names a whole-value reshape function (see _RESHAPE_KINDS) for
      mappings that aren't a positional 1:1 copy at all -- set_history is
      the one example so far (many raw values -> one named list). A mapping
      with `kind` set ignores `value_map` (the reshape function owns the
      entire transform).

    Deliberately builds a brand-new dict rather than mutating the subscriber's
    persisted config -- mirrors deriveFromScoreboardConfig's own contract
    (frontend-overlays/lib/match-info-rules.ts): this is *only* the derived
    subset, merged by the caller against whatever the subscriber's own
    config/defaults already have, same read-path precedence SPEC_OVERLAY_PACKS.md
    §2.3 already established. A path that fails to resolve is silently skipped,
    not defaulted to None here -- same reasoning, the caller's own precedence
    chain is what should decide the fallback, not this function guessing one.
    """
    derived: dict = {}
    for mapping in subscriber_template.field_mappings:
        if str(mapping['source_template_id']) != str(source_template_id):
            continue
        if selected_keys is not None and mapping['key'] not in selected_keys:
            continue
        value = _get_by_path(source_config, mapping['source_path'])
        if value is _UNSET:
            continue

        kind = mapping.get('kind')
        if kind is not None:
            value = _RESHAPE_KINDS[kind](value)
        else:
            value_map = mapping.get('value_map')
            if value_map is not None:
                value = value_map.get(str(value), value)

        _set_by_path(derived, mapping['target_path'], value)
    return derived


# --- Per-connection field selection ---
#
# field_mappings is catalog-level (an OverlayTemplate can declare 20 possible
# fields), but which of those 20 actually apply is a property of ONE
# connection -- one specific paired ProjectSession -- not the template. Two
# Match Info Card sessions paired to the same scoreboard source can each
# want a different subset (or the full 20) without the template changing.
#
# New model field, same lifecycle as subscribed_session/sync_mode (set once
# at pairing time, via set_session_subscription):
#
#   class ProjectSession(...):
#       ...
#       mapped_fields = models.JSONField(default=list, blank=True)
#       # [] (default) = every field_mappings entry for the paired source
#       # template resolves, unchanged from every session that pairs today.
#       # Non-empty = an allow-list of `key`s -- ONLY those entries resolve
#       # for this session, even though the template still declares all 20.
#
# set_session_subscription (services/packs.py) gains one more optional
# kwarg, validated the same way apply_control_overrides rejects an unknown
# control_fields path -- an unknown key here should 400, not silently no-op:
#
#   def set_session_subscription(session, subscribed_session_id, sync_mode,
#                                 owner_id, created_by, selected_fields=None):
#       ...
#       if selected_fields:
#           own_template = _get_template_or_raise(session.project.template_id)
#           valid_keys = {
#               m['key'] for m in own_template.field_mappings
#               if str(m['source_template_id']) == str(source.project.template_id)
#           }
#           unknown = set(selected_fields) - valid_keys
#           if unknown:
#               raise OverlayError.OVERLAYS_003(format_kwargs={'path': ', '.join(sorted(unknown))})
#           session.mapped_fields = selected_fields
#       ...
#
# fan_out_to_subscribers (below) is the only other call site that changes --
# it reads each subscriber's own mapped_fields and turns [] into None (see
# resolve_mapped_config's own docstring on why that translation is its
# caller's job, not baked into the resolver itself).


def fan_out_to_subscribers(source_session: ProjectSession) -> None:
    """Call this right after a source session's config is persisted (today:
    ProjectSessionViewSet.state, right where it already does
    `broadcast_group_event(f'overlay_session_{session.id}', 'overlay_state',
    session.config)` for the source's OWN primary render connection -- this is
    the same event, fanned out to every AUTOMATIC subscriber's group too,
    instead of leaving each subscriber's render page to open its own second WS
    connection back to this session and derive client-side).

    Only ever touches OTHER sessions' config -- source_session's own row/config
    is untouched here, this function has nothing to do with it beyond reading
    from it.

    select_related('project__template') because resolve_mapped_config needs
    each subscriber's own template.field_mappings -- N+1 otherwise, one query
    per subscriber in a pack that could plausibly have several.
    """
    subscribers = ProjectSession.objects.filter(
        subscribed_session=source_session,
        sync_mode=ProjectSessionSyncMode.AUTOMATIC,
    ).select_related('project__template')

    for subscriber in subscribers:
        derived = resolve_mapped_config(
            subscriber.project.template,
            source_template_id=source_session.project.template_id,
            source_config=source_session.config,
            # [] (never explicitly selected) -> None -- "resolve everything",
            # matching every session that pairs without ever touching
            # mapped_fields today. A real (non-empty) selection narrows it
            # to just those keys, per-connection, per resolve_mapped_config's
            # own docstring above.
            selected_keys=set(subscriber.mapped_fields) or None,
        )
        if not derived:
            continue

        # Shallow top-level merge, matching update_session_state's own "raw
        # inputs, no deep merge" posture elsewhere in this app -- a mapping
        # that targets "score.games.1" fully owns the "score" key once
        # resolved; it doesn't attempt to preserve subscriber.config["score"]
        # keys the mapping table itself didn't declare.
        subscriber.config = {**subscriber.config, **derived}
        subscriber.save(update_fields=['config', 'updated_at'])

        from apps.common.ws.consumers import broadcast_group_event

        broadcast_group_event(f'overlay_session_{subscriber.id}', 'overlay_state', subscriber.config)


# --- Sketch of the one call site (apps/overlays/api/views.py, ProjectSessionViewSet.state) ---
#
#     @decorators.action(detail=True, methods=['POST'])
#     def state(self, request, *args, **kwargs):
#         session = self.get_object()
#         session = update_session_state(session, request.data)
#         broadcast_group_event(f'overlay_session_{session.id}', 'overlay_state', session.config)
#         fan_out_to_subscribers(session)   # <-- new
#         return Response(ProjectSessionSerializer(session).data)


# --- Worked example: a 3rd-party-facing "match state" export, NOT Match Info
# Card's own persisted config (MatchInfoConfig only has tournamentName/round/
# player names -- no score at all, see frontend-overlays/interfaces/
# match-info.interface.ts). Same resolve_mapped_config machinery, a different
# subscriber_template.field_mappings list -- this is the point of keeping the
# resolver generic: Match Info Card and an external "match-state" template
# both derive from the *same* padel scoreboard source, each declaring only
# the subset/shape it needs. ---
#
# Real source_config, live in the local cluster's sink DB right now
# (overlays_projectsession, one row) -- see field_mapping_poc's sibling
# investigation this session. team/player names are this row's actual test
# data, not placeholders:

PADEL_SCOREBOARD_TEMPLATE_ID = '00000000-0000-0000-0000-000000000001'  # placeholder

SOURCE_CONFIG_EXAMPLE = {
    'sport': 'padel',
    'score': {
        'games': [0, 0],
        'points': ['15', '30'],
        'server': {'team': 0, 'player': 0},
        'setsWon': [0, 0],
        'deuceCycle': 0,
        'isTiebreak': False,
        # empty on this live row -- shown non-empty below in
        # SOURCE_CONFIG_WITH_COMPLETED_SETS to actually exercise the
        # set_history reshape kind.
        'setHistory': [],
        'matchWinner': None,
        'nextServerPlayerByTeam': [0, 0],
    },
    'teams': [
        {'color': '#6290C3', 'players': [{'name': 'test'}, {'name': 'Team A 2'}]},
        {'color': '#C4D600', 'players': [{'name': 'ssss'}, {'name': 'Team B 2'}]},
    ],
    # Real values off the same live row -- match-wide, no team association at
    # all, exactly the case the metadata section below exists for.
    'format': {'bestOf': 3, 'deuceRule': 'doubleAdvantage', 'finalSetTiebreak': True},
    'situationalMessage': {'enabled': True, 'tournamentPoint': False},
}

# field_mappings a hypothetical "match-state" export template would carry --
# note target_path still uses plain dot-paths (_set_by_path doesn't need to
# change) -- "grouped by team, named not indexed" comes entirely from
# *choosing* team_a/team_b/player_1/player_2 as the literal path segments,
# plus value_map turning server's raw 0/1 into the same names.
MATCH_STATE_FIELD_MAPPINGS = [
    {
        'key': 'team_a_player_1',
        'source_template_id': PADEL_SCOREBOARD_TEMPLATE_ID,
        'source_path': 'teams.0.players.0.name',
        'target_path': 'teams.team_a.player_1',
        'label': 'Team A - Player 1',
    },
    {
        'key': 'team_a_player_2',
        'source_template_id': PADEL_SCOREBOARD_TEMPLATE_ID,
        'source_path': 'teams.0.players.1.name',
        'target_path': 'teams.team_a.player_2',
        'label': 'Team A - Player 2',
    },
    {
        'key': 'team_b_player_1',
        'source_template_id': PADEL_SCOREBOARD_TEMPLATE_ID,
        'source_path': 'teams.1.players.0.name',
        'target_path': 'teams.team_b.player_1',
        'label': 'Team B - Player 1',
    },
    {
        'key': 'team_b_player_2',
        'source_template_id': PADEL_SCOREBOARD_TEMPLATE_ID,
        'source_path': 'teams.1.players.1.name',
        'target_path': 'teams.team_b.player_2',
        'label': 'Team B - Player 2',
    },
    {
        'key': 'team_a_games',
        'source_template_id': PADEL_SCOREBOARD_TEMPLATE_ID,
        'source_path': 'score.games.0',
        'target_path': 'score.team_a.games',
        'label': 'Team A Games',
    },
    {
        'key': 'team_a_points',
        'source_template_id': PADEL_SCOREBOARD_TEMPLATE_ID,
        'source_path': 'score.points.0',
        'target_path': 'score.team_a.points',
        'label': 'Team A Points',
    },
    {
        'key': 'team_b_games',
        'source_template_id': PADEL_SCOREBOARD_TEMPLATE_ID,
        'source_path': 'score.games.1',
        'target_path': 'score.team_b.games',
        'label': 'Team B Games',
    },
    {
        'key': 'team_b_points',
        'source_template_id': PADEL_SCOREBOARD_TEMPLATE_ID,
        'source_path': 'score.points.1',
        'target_path': 'score.team_b.points',
        'label': 'Team B Points',
    },
    {
        'key': 'serve_team',
        'source_template_id': PADEL_SCOREBOARD_TEMPLATE_ID,
        'source_path': 'score.server.team',
        'target_path': 'score.serve.team',
        'value_map': {'0': 'team_a', '1': 'team_b'},
        'label': 'Serving Team',
    },
    {
        'key': 'serve_player',
        'source_template_id': PADEL_SCOREBOARD_TEMPLATE_ID,
        'source_path': 'score.server.player',
        'target_path': 'score.serve.player',
        'value_map': {'0': 'player_1', '1': 'player_2'},
        'label': 'Serving Player',
    },
    {
        'key': 'completed_sets',
        'source_template_id': PADEL_SCOREBOARD_TEMPLATE_ID,
        'source_path': 'score.setHistory',
        'target_path': 'score.sets',
        'kind': 'set_history',
        'label': 'Completed Sets',
    },
    # Fields with no team association at all -- the control_fields analogue
    # would leave `subgroup` unset (Player Mode/Logo/Background/Ticker,
    # above). Same rule here: a field belonging to neither side still gets a
    # mapping entry, it just targets the reserved `metadata` bucket instead
    # of `teams`/`score` -- streamed on every event, never silently dropped
    # just because it isn't team-scoped. No new schema key needed for this
    # -- target_path's own first segment ("metadata.*") already says where
    # it belongs, the same way "teams.*"/"score.*" do above.
    {
        'key': 'sport',
        'source_template_id': PADEL_SCOREBOARD_TEMPLATE_ID,
        'source_path': 'sport',
        'target_path': 'metadata.sport',
        'label': 'Sport',
    },
    {
        'key': 'format_best_of',
        'source_template_id': PADEL_SCOREBOARD_TEMPLATE_ID,
        'source_path': 'format.bestOf',
        'target_path': 'metadata.format.best_of',
        'label': 'Best Of',
    },
    {
        'key': 'format_deuce_rule',
        'source_template_id': PADEL_SCOREBOARD_TEMPLATE_ID,
        'source_path': 'format.deuceRule',
        'target_path': 'metadata.format.deuce_rule',
        'label': 'Deuce Rule',
    },
    {
        'key': 'tournament_point',
        'source_template_id': PADEL_SCOREBOARD_TEMPLATE_ID,
        'source_path': 'situationalMessage.tournamentPoint',
        'target_path': 'metadata.tournament_point',
        'label': 'Tournament Point',
    },
]

# resolve_mapped_config(subscriber_template_with_the_list_above,
#                        PADEL_SCOREBOARD_TEMPLATE_ID, SOURCE_CONFIG_EXAMPLE)
# produces:
RESOLVED_OUTPUT_EXAMPLE = {
    'teams': {
        'team_a': {'player_1': 'test', 'player_2': 'Team A 2'},
        'team_b': {'player_1': 'ssss', 'player_2': 'Team B 2'},
    },
    'score': {
        'team_a': {'games': 0, 'points': '15'},
        'team_b': {'games': 0, 'points': '30'},
        'serve': {'team': 'team_a', 'player': 'player_1'},
        'sets': [],  # setHistory is empty on this live row
    },
    'metadata': {
        'sport': 'padel',
        'format': {'best_of': 3, 'deuce_rule': 'doubleAdvantage'},
        'tournament_point': False,
    },
}

# Same mapping list, against a config where two sets have actually finished
# (hand-constructed, not the live row -- to actually exercise set_history):
SOURCE_CONFIG_WITH_COMPLETED_SETS = {
    **SOURCE_CONFIG_EXAMPLE,
    'score': {**SOURCE_CONFIG_EXAMPLE['score'], 'setHistory': [[6, 4], [3, 6]]},
}
# resolve_mapped_config(..., SOURCE_CONFIG_WITH_COMPLETED_SETS) instead produces
# the same teams/score.team_a/score.team_b/score.serve as above, plus:
#   "sets": [
#     {"set_number": 1, "team_a_games": 6, "team_b_games": 4},
#     {"set_number": 2, "team_a_games": 3, "team_b_games": 6},
#   ]
#
# That's the full self-describing shape from the earlier message, grouped by
# team, one entry per completed set instead of two independently-appended
# per-team lists -- any consumer (frontend-overlays' own render pages, or a
# genuine 3rd party with zero access to this codebase) can read team_a.games,
# serve.team, or sets[0].team_b_games directly, with no positional-index
# knowledge and no separate schema document required.


# --- Worked example: ONE connection selecting 3 of these 15 fields ---
#
# Same template (MATCH_STATE_FIELD_MAPPINGS, all 15 entries, unchanged), same
# source config -- only the CONNECTION differs. This is
# ProjectSession.mapped_fields on one specific paired session (e.g. a
# minimal on-court ticker that only ever needs to show games, not full
# match state) -- a different session paired to the very same source could
# still get all 15, or a different 3, without touching the template.
CONNECTION_SELECTED_KEYS = {'team_a_games', 'team_b_games', 'serve_team'}

# resolve_mapped_config(subscriber_template_with_the_list_above,
#                        PADEL_SCOREBOARD_TEMPLATE_ID, SOURCE_CONFIG_EXAMPLE,
#                        selected_keys=CONNECTION_SELECTED_KEYS)
# produces only:
CONNECTION_RESOLVED_OUTPUT_EXAMPLE = {
    'score': {
        'team_a': {'games': 0},
        'team_b': {'games': 0},
        'serve': {'team': 'team_a'},
    },
}
# No `teams` section, no `points`/`serve.player`/`sets`/`metadata` at all --
# not because those mappings don't exist (they're still right there in
# MATCH_STATE_FIELD_MAPPINGS), but because this one connection never asked
# for them. A 3rd party or a stripped-down UI wiring up this specific
# connection only ever sees the 3 fields it selected, self-describing under
# the exact same key names (score.team_a.games etc.) it would get from a
# full-catalog connection -- selection changes WHICH fields show up, never
# how any individual field is shaped.


# --- OverlayTemplate.control_fields: new `subgroup` key ---
#
# control_fields and field_mappings address two DIFFERENT config shapes on
# the same template -- control_fields governs OverlayProject.config
# (TEMPLATE_CONTROLLED mode's `elements.N.content` shape, validated by
# apply_control_overrides); field_mappings (above) governs
# ProjectSession.config (INTERACTIVE mode's `score`/`teams` shape).
# INTERACTIVE explicitly never reads control_fields today
# (apps/overlays/services/interactive.py's own docstring on
# OverlayProject.template) -- that boundary is intentional, not crossed
# here. `subgroup` below is scoped ONLY to control_fields' own existing
# purpose (the TEMPLATE_CONTROLLED edit-panel UI's grouping) -- it does NOT
# feed field_mappings' resolution, and resolve_mapped_config above never
# reads it. The two lists still have to be hand-kept-consistent on which
# strings mean "team_a" / "team_b" -- unifying them for real would mean
# widening control_fields to also cover INTERACTIVE templates, a bigger,
# separate boundary-crossing decision, not this addition.
#
# Purely additive: control_fields is a raw passthrough JSONField (no nested
# per-item serializer -- OverlayTemplateSerializer exposes it read-only as-is),
# and apply_control_overrides._validate_value only ever reads `path`/
# `input_type`+its own constraints, ignoring unknown keys already -- so no
# migration, no validator change, and every existing seed entry keeps
# working untouched whether or not it declares `subgroup`.
#
# Real entries from apps/overlays/services/seed_templates.py's "Padel /
# Tennis Scoreboard", with `subgroup` added -- omitted (or None) on
# match-wide fields that don't belong to either side (Player Mode, Logo,
# Background Color, Commentary Ticker):

PADEL_SCOREBOARD_CONTROL_FIELDS_WITH_SUBGROUP = [
    {
        'path': 'playerMode',
        'label': 'Player Mode',
        'input_type': 'select',
        'options': ['singles', 'doubles'],
        'group': 'Setup',
        # no subgroup -- applies to the whole match, not one side
    },
    {
        'path': 'elements.1.content',
        'label': 'Team A - Player 1',
        'input_type': 'text',
        'max_length': 32,
        'group': 'Names',
        'subgroup': 'team_a',
    },
    {
        'path': 'elements.2.content',
        'label': 'Team A - Player 2',
        'input_type': 'text',
        'max_length': 32,
        'group': 'Names',
        'subgroup': 'team_a',
    },
    {
        'path': 'elements.3.content',
        'label': 'Team B - Player 1',
        'input_type': 'text',
        'max_length': 32,
        'group': 'Names',
        'subgroup': 'team_b',
    },
    {
        'path': 'elements.4.content',
        'label': 'Team B - Player 2',
        'input_type': 'text',
        'max_length': 32,
        'group': 'Names',
        'subgroup': 'team_b',
    },
    {
        'path': 'elements.5.content',
        'label': 'Team A Points',
        'input_type': 'counter',
        'labels': ['0', '15', '30', '40', 'Ad'],
        'group': 'Score',
        'subgroup': 'team_a',
    },
    {
        'path': 'elements.6.content',
        'label': 'Team B Points',
        'input_type': 'counter',
        'labels': ['0', '15', '30', '40', 'Ad'],
        'group': 'Score',
        'subgroup': 'team_b',
    },
    {
        'path': 'elements.7.content',
        'label': 'Team A Games',
        'input_type': 'counter',
        'min': 0,
        'max': 7,
        'group': 'Score',
        'subgroup': 'team_a',
    },
    {
        'path': 'elements.8.content',
        'label': 'Team B Games',
        'input_type': 'counter',
        'min': 0,
        'max': 7,
        'group': 'Score',
        'subgroup': 'team_b',
    },
    {
        'path': 'elements.9.content',
        'label': 'Team A Sets',
        'input_type': 'counter',
        'min': 0,
        'max': 5,
        'group': 'Score',
        'subgroup': 'team_a',
    },
    {
        'path': 'elements.10.content',
        'label': 'Team B Sets',
        'input_type': 'counter',
        'min': 0,
        'max': 5,
        'group': 'Score',
        'subgroup': 'team_b',
    },
    {
        'path': 'elements.0.src',
        'label': 'Logo',
        'input_type': 'image',
        'group': 'Appearance',
        # no subgroup -- shared, not per-side
    },
    {
        'path': 'background',
        'label': 'Background Color',
        'input_type': 'color',
        'group': 'Appearance',
    },
    {
        'path': 'elements.11.items',
        'label': 'Commentary Ticker',
        'input_type': 'string_list',
        'max_items': 5,
        'group': 'Appearance',
    },
]

# What this buys the edit-panel UI, concretely: instead of one flat "Score"
# section listing Team A Points / Team B Points / Team A Games / Team B
# Games / Team A Sets / Team B Sets in path order, the builder can group by
# (group, subgroup) -- a "Score" section with two columns, "Team A" and
# "Team B" -- the exact same team_a/team_b vocabulary field_mappings' own
# target_path segments use, just not programmatically shared between the
# two lists yet (see the boundary note above).
