from __future__ import annotations

import asyncio
import hashlib
import hmac
import json
import re
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Callable

from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import and_, delete, desc, select

from .auth_bridge import HockeyUser
from .database import HockeyDatabase
from .mapping_context import MappingDataService
from .models import (GameControlState, MappingSqlDataSource, OperatorSession, UserPreference, VmixAssignment, VmixDevice, VmixMappingProfile, VmixMappingField, VmixPreparedTitle)


AGENT_PROTOCOL_VERSION = 1
AGENT_ONLINE_WINDOW_SECONDS = 35
_DEVICE_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{6,128}$")

# BUILD97: Mapping can easily exceed 300 links. Keep WebSocket/vMix packets small
# and predictable instead of sending the whole profile in one giant batch.
MAPPING_BATCH_MAX_COMMANDS = 40
MAPPING_BATCH_MAX_BYTES = 48 * 1024


def _utcnow() -> datetime:
    return datetime.utcnow()


def _secret_hash(value: str) -> str:
    return hashlib.sha256(value.encode("utf-8")).hexdigest()


_MAPPING_TEST_FUNCTIONS = {
    "SetText", "SetImage", "SetColor",
    "SetTextColour",
    "SetTextVisibleOn", "SetTextVisibleOff",
    "SetImageVisibleOn", "SetImageVisibleOff",
}


def _mapping_value_function(field_type: Any) -> str:
    kind = str(field_type or "text").strip().lower()
    if kind in {"image", "source"}:
        return "SetImage"
    if kind in {"color", "colour"}:
        return "SetColor"
    return "SetText"


def _mapping_rule_payload(value: Any) -> dict[str, Any]:
    if isinstance(value, dict):
        return dict(value)
    try:
        parsed = json.loads(str(value or "{}"))
    except Exception:
        return {}
    return parsed if isinstance(parsed, dict) else {}


def _stable_vmix_inventory_fingerprint(inputs: list[dict[str, Any]]) -> str:
    """Fingerprint a vMix project without positional Input numbers/order.

    vMix ``number`` is a slot position and changes when an operator moves an
    Input.  ``key`` is the durable identity, with title as fallback for old
    inventories. Field indices are positional too, so only names/types are part
    of the identity.
    """
    stable_inputs: list[dict[str, Any]] = []
    for item in inputs:
        if not isinstance(item, dict):
            continue
        key = str(item.get("key") or "").strip()
        title = str(item.get("title") or "").strip()
        identity = key or title
        if not identity:
            # Last-resort legacy inventory. This is intentionally weaker, but
            # avoids dropping projects produced by very old Agents.
            identity = f"legacy:{str(item.get('number') or '').strip()}"
        fields = item.get("fields") if isinstance(item.get("fields"), list) else []
        stable_fields = sorted(
            (
                {
                    "name": str(field.get("name") or "").strip(),
                    "type": str(field.get("type") or "text").strip().lower(),
                }
                for field in fields
                if isinstance(field, dict) and str(field.get("name") or "").strip()
            ),
            key=lambda value: (value["name"].casefold(), value["type"]),
        )
        stable_inputs.append({
            "identity": identity,
            "key": key,
            # A title rename must not create a new project when vMix supplied a
            # durable key. For legacy inputs without key the title is identity.
            "title": "" if key else title,
            "type": str(item.get("type") or "").strip().lower(),
            "fields": stable_fields,
        })
    stable_inputs.sort(key=lambda value: (value["identity"].casefold(), value["title"].casefold()))
    payload = json.dumps(stable_inputs, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()


def _stored_inventory_stable_fingerprint(value: Any) -> str:
    try:
        payload = json.loads(str(value or "{}")) if not isinstance(value, dict) else value
    except Exception:
        return ""
    inputs = payload.get("inputs") if isinstance(payload, dict) and isinstance(payload.get("inputs"), list) else []
    return _stable_vmix_inventory_fingerprint(inputs) if inputs else ""


def _mapping_numeric_value(value: Any) -> float | None:
    if value is None or isinstance(value, bool):
        return None
    if isinstance(value, (int, float)):
        return float(value)
    text_value = str(value).strip()
    if not text_value:
        return None
    compact = text_value.replace(" ", "").replace("%", "")
    if re.fullmatch(r"[-+]?\d{1,4}:\d{1,2}(?::\d{1,2}(?:[.,]\d+)?)?", compact):
        parts = compact.replace(",", ".").split(":")
        try:
            numbers = [float(part) for part in parts]
        except ValueError:
            return None
        if len(numbers) == 2:
            return numbers[0] * 60 + numbers[1]
        if len(numbers) == 3:
            return numbers[0] * 3600 + numbers[1] * 60 + numbers[2]
    try:
        return float(compact.replace(",", "."))
    except ValueError:
        return None


def _mapping_rule_matches(left: Any, operator: Any, right: Any = None) -> bool:
    op = str(operator or "eq").strip().lower()
    left_text = "" if left is None else str(left).strip()
    right_text = "" if right is None else str(right).strip()
    if op in {"empty", "is_empty"}:
        return left_text == ""
    if op in {"not_empty", "is_not_empty"}:
        return left_text != ""
    if op in {"contains", "not_contains"}:
        matched = right_text.casefold() in left_text.casefold()
        return (not matched) if op == "not_contains" else matched

    left_number = _mapping_numeric_value(left)
    right_number = _mapping_numeric_value(right)
    if left_number is not None and right_number is not None:
        a: Any = left_number
        b: Any = right_number
    else:
        a = left_text.casefold()
        b = right_text.casefold()
    if op in {"gt", ">"}: return a > b
    if op in {"lt", "<"}: return a < b
    if op in {"gte", ">="}: return a >= b
    if op in {"lte", "<="}: return a <= b
    if op in {"neq", "!=", "<>"}: return a != b
    return a == b


def _mapping_rule_command(
    rule: dict[str, Any],
    *,
    field_type: Any,
    input_ref: str,
    selected_name: str,
    default_data_key: str,
    by_key: dict[str, dict[str, Any]],
) -> tuple[dict[str, Any] | None, str]:
    if not rule or not bool(rule.get("enabled")):
        return None, ""
    kind = str(field_type or "text").strip().lower()
    action = str(rule.get("action") or ("visibility" if kind in {"image", "source"} else "text_colour")).strip().lower()
    if action == "text_colour" and kind != "text":
        return None, "unsupported_action"
    if action == "visibility" and kind not in {"text", "image", "source"}:
        return None, "unsupported_action"

    left_key = str(rule.get("left_key") or default_data_key or "").strip()
    left_item = by_key.get(left_key)
    if left_item is None:
        return None, f"missing_left:{left_key}"
    left_value = left_item.get("value")
    operator = str(rule.get("operator") or "gt").strip().lower()
    right_mode = str(rule.get("right_mode") or "field").strip().lower()
    right_value: Any = None
    if operator not in {"empty", "is_empty", "not_empty", "is_not_empty"}:
        if right_mode == "value":
            right_value = rule.get("right_value", "")
        else:
            right_key = str(rule.get("right_key") or "").strip()
            right_item = by_key.get(right_key)
            if right_item is None:
                return None, f"missing_right:{right_key}"
            right_value = right_item.get("value")

    matched = _mapping_rule_matches(left_value, operator, right_value)
    if action == "text_colour":
        chosen = rule.get("true_value", "#E5CEA8") if matched else rule.get("false_value", "#FFFFFF")
        if chosen is None or str(chosen).strip() == "":
            return None, ""
        return {
            "Function": "SetTextColour",
            "Input": input_ref,
            "SelectedName": selected_name,
            "Value": str(chosen).strip(),
        }, ""

    raw_state = rule.get("true_value", "on") if matched else rule.get("false_value", "off")
    visible = str(raw_state).strip().lower() not in {"0", "false", "off", "hide", "hidden", "no"}
    if kind in {"image", "source"}:
        function = "SetImageVisibleOn" if visible else "SetImageVisibleOff"
    else:
        function = "SetTextVisibleOn" if visible else "SetTextVisibleOff"
    return {"Function": function, "Input": input_ref, "SelectedName": selected_name}, ""


@dataclass(slots=True)
class LiveAgent:
    websocket: WebSocket
    connected_at: datetime


class VmixAgentHub:
    """Persistent device registry + live WebSocket routing for vMix agents."""

    def __init__(self, database: HockeyDatabase, settings: Any | None = None) -> None:
        self.database = database
        self._live: dict[str, LiveAgent] = {}
        self._lock = asyncio.Lock()
        self._pending_commands: dict[str, tuple[str, asyncio.Future[dict[str, Any]]]] = {}
        # BUILD90: one FIFO lock per Agent. Shortcut batches, Mapping refreshes and
        # timer control commands can no longer interleave on the same vMix instance.
        self._vmix_send_locks: dict[str, asyncio.Lock] = {}
        # BUILD97: serialize Mapping applications per Agent while still letting
        # runtime/timer commands use the normal vMix FIFO between Mapping chunks.
        self._mapping_apply_locks: dict[str, asyncio.Lock] = {}
        # BUILD102: interactive operator shortcuts use their own tiny send lock.
        # They must never sit behind Mapping's ACK wait, otherwise a visible F-key
        # or bottom-dock button can feel dead even though vMix itself is reachable.
        self._vmix_shortcut_send_locks: dict[str, asyncio.Lock] = {}
        self.mapping_data = MappingDataService(database, settings=settings)
        self._auto_refresh_task: asyncio.Task[None] | None = None
        self._auto_refresh_next: dict[tuple[str, str], float] = {}
        self._mapping_value_cache: dict[tuple[str, str, int, str, str, str, str], str] = {}
        # BUILD83: server-side mirror of runtime Overlay state.
        self._runtime_overlay_state: dict[str, dict[str, dict[str, str]]] = {}
        # BUILD115: true when Overlay state comes from the Agent reading the actual
        # local vMix XML API rather than from optimistic command history.
        self._runtime_overlay_authoritative: dict[str, bool] = {}
        self._runtime_overlay_observed_at: dict[str, str] = {}
        self._auto_refresh_last_error = ""

    @staticmethod
    def normalise_device_id(value: Any) -> str:
        device_id = str(value or "").strip()
        if not _DEVICE_ID_RE.fullmatch(device_id):
            raise ValueError("Некорректный device_id")
        return device_id

    @staticmethod
    def _runtime_overlay_command(function_name: Any) -> tuple[str, str] | None:
        text = re.sub(r"\s+", "", str(function_name or ""))
        if text.lower() == "overlayinputalloff":
            return ("all", "off")
        match = re.search(r"OverlayInput([1-8])(In|Out|Off)?", text, flags=re.IGNORECASE)
        if not match:
            return None
        raw = str(match.group(2) or "toggle").lower()
        action = "in" if raw == "in" else "out" if raw == "out" else "off" if raw == "off" else "toggle"
        return str(match.group(1)), action

    def _track_runtime_overlay_command(
        self,
        device_id: str,
        command: dict[str, Any],
        *,
        sequence_id: str = "",
        sequence_name: str = "",
        button_id: str = "",
    ) -> None:
        parsed = self._runtime_overlay_command(command.get("Function") or command.get("function"))
        if parsed is None:
            return
        # A command only tells us what we asked vMix to do. Until the Agent reads
        # the XML API again, this local mirror is optimistic rather than a tally.
        if not hasattr(self, "_runtime_overlay_authoritative"):
            self._runtime_overlay_authoritative = {}
        self._runtime_overlay_authoritative[device_id] = False
        layer, action = parsed
        device_state = self._runtime_overlay_state.setdefault(device_id, {})
        if layer == "all":
            device_state.clear()
            return
        input_ref = str(command.get("Input") or command.get("input") or "").strip()
        if action == "in":
            device_state[layer] = {
                "input": input_ref,
                "sequence_id": str(sequence_id or ""),
                "sequence_name": str(sequence_name or ""),
                "button_id": str(button_id or ""),
                "updated_at": _utcnow().isoformat(),
            }
            return
        if action in {"out", "off"}:
            device_state.pop(layer, None)
            return
        current = device_state.get(layer)
        if current and (not input_ref or str(current.get("input") or "") == input_ref):
            device_state.pop(layer, None)
        else:
            device_state[layer] = {
                "input": input_ref,
                "sequence_id": str(sequence_id or ""),
                "sequence_name": str(sequence_name or ""),
                "button_id": str(button_id or ""),
                "updated_at": _utcnow().isoformat(),
            }

    def _runtime_overlay_payload(self, device_id: str) -> dict[str, Any]:
        source = self._runtime_overlay_state.get(device_id, {})
        overlays = {str(layer): dict(value) for layer, value in source.items() if str(layer) in {"1", "2", "3", "4", "5", "6", "7", "8"}}
        authoritative = bool(getattr(self, "_runtime_overlay_authoritative", {}).get(device_id, False))
        observed = str(getattr(self, "_runtime_overlay_observed_at", {}).get(device_id) or "")
        return {
            "device_id": device_id,
            "overlays": overlays,
            "authoritative": authoritative,
            "source": "agent_tally" if authoritative else "command_mirror",
            "observed_at": observed,
        }

    def runtime_overlay_state_for_user(self, user: HockeyUser, *, device_id: str = "") -> dict[str, Any]:
        requested = self.normalise_device_id(device_id) if str(device_id or "").strip() else ""
        with self.database.session() as session:
            if requested:
                device = session.scalar(select(VmixDevice).where(and_(VmixDevice.device_uuid == requested, VmixDevice.wfl_user_id == user.id)))
                if device is None:
                    raise HTTPException(status_code=404, detail="Agent не принадлежит текущему аккаунту")
            else:
                rows = list(session.scalars(
                    select(VmixDevice)
                    .where(and_(VmixDevice.wfl_user_id == user.id, VmixDevice.is_active_for_account.is_(True)))
                    .order_by(desc(VmixDevice.last_seen_at))
                ))
                if not rows:
                    return {"device_id": "", "overlays": {}}
                device = rows[0]
            result = self._runtime_overlay_payload(device.device_uuid)
            result["online"] = device.device_uuid in self._live
            result["vmix_connected"] = bool(device.vmix_connected)
            return result

    @staticmethod
    def _mapping_source_code(data_key: Any) -> str:
        key = str(data_key or "").strip()
        return key.split(".", 1)[0] if key else ""

    @staticmethod
    def _agent_version_tuple(version: Any) -> tuple[int, int, int] | None:
        match = re.search(r"(\d+)\.(\d+)\.(\d+)", str(version or ""))
        if not match:
            return None
        return tuple(int(part) for part in match.groups())

    @classmethod
    def _agent_supports_mapping_inventory(cls, version: Any) -> bool:
        parsed = cls._agent_version_tuple(version)
        return bool(parsed is not None and parsed >= (1, 3, 0))

    @classmethod
    def _agent_supports_batch(cls, version: Any) -> bool:
        parsed = cls._agent_version_tuple(version)
        return bool(parsed is not None and parsed >= (1, 4, 0))


    @staticmethod
    def _inventory_target_pairs(value: Any) -> set[tuple[str, str]]:
        """Return stable ``(Input key, field name)`` pairs from an inventory."""
        try:
            payload = json.loads(str(value or "{}")) if not isinstance(value, dict) else value
        except Exception:
            return set()
        inputs = payload.get("inputs") if isinstance(payload, dict) and isinstance(payload.get("inputs"), list) else []
        pairs: set[tuple[str, str]] = set()
        for item in inputs:
            if not isinstance(item, dict):
                continue
            input_key = str(item.get("key") or "").strip()
            if not input_key:
                continue
            fields = item.get("fields") if isinstance(item.get("fields"), list) else []
            for field in fields:
                if not isinstance(field, dict):
                    continue
                field_name = str(field.get("name") or "").strip()
                if field_name:
                    pairs.add((input_key, field_name))
        return pairs

    def _compatible_mapping_profile_for_device(self, session: Any, device: VmixDevice) -> VmixMappingProfile | None:
        """Recover a profile when the project changed only in unrelated Inputs.

        A profile is considered compatible only when *all* enabled mapped targets
        identified by stable vMix Input keys still exist in the current project. If
        several profiles tie for the best coverage we deliberately do not guess.
        """
        current_pairs = self._inventory_target_pairs(device.project_inventory_json)
        if not current_pairs:
            return None
        candidates: list[tuple[int, VmixMappingProfile]] = []
        for profile in session.scalars(
            select(VmixMappingProfile)
            .where(VmixMappingProfile.active.is_(True))
            .order_by(desc(VmixMappingProfile.updated_at), desc(VmixMappingProfile.id))
        ):
            rows = list(session.scalars(
                select(VmixMappingField).where(and_(
                    VmixMappingField.profile_id == profile.id,
                    VmixMappingField.enabled.is_(True),
                ))
            ))
            targets = {
                (str(row.vmix_input_key or "").strip(), str(row.vmix_field or "").strip())
                for row in rows
                if str(row.vmix_input_key or "").strip() and str(row.vmix_field or "").strip()
            }
            if not targets or not targets.issubset(current_pairs):
                continue
            candidates.append((len(targets), profile))
        if not candidates:
            return None
        best_score = max(score for score, _ in candidates)
        best = [profile for score, profile in candidates if score == best_score]
        return best[0] if len(best) == 1 else None

    def _active_mapping_profile_for_device(self, session: Any, device: VmixDevice) -> VmixMappingProfile | None:
        """Find/migrate the mapping profile for a device using stable inventory identity."""
        fingerprint = str(device.project_fingerprint or "").strip()
        if fingerprint:
            profile = session.scalar(
                select(VmixMappingProfile)
                .where(and_(VmixMappingProfile.project_fingerprint == fingerprint, VmixMappingProfile.active.is_(True)))
                .order_by(desc(VmixMappingProfile.updated_at), desc(VmixMappingProfile.id))
            )
            if profile is not None:
                return profile

        try:
            inventory = json.loads(device.project_inventory_json or "{}")
        except Exception:
            inventory = {}
        stable_fingerprint = _stored_inventory_stable_fingerprint(inventory)
        agent_fingerprint = str(inventory.get("agent_fingerprint") or "").strip().lower() if isinstance(inventory, dict) else ""

        # Legacy build <=39 profile: raw Agent fingerprint was stored directly.
        if agent_fingerprint:
            profile = session.scalar(
                select(VmixMappingProfile)
                .where(and_(VmixMappingProfile.project_fingerprint == agent_fingerprint, VmixMappingProfile.active.is_(True)))
                .order_by(desc(VmixMappingProfile.updated_at), desc(VmixMappingProfile.id))
            )
            if profile is not None:
                if stable_fingerprint:
                    profile.project_fingerprint = stable_fingerprint
                    device.project_fingerprint = stable_fingerprint
                return profile

        if stable_fingerprint:
            candidates = list(session.scalars(
                select(VmixMappingProfile)
                .where(VmixMappingProfile.active.is_(True))
                .order_by(desc(VmixMappingProfile.updated_at), desc(VmixMappingProfile.id))
            ))
            profile = next((candidate for candidate in candidates
                            if _stored_inventory_stable_fingerprint(candidate.inventory_json) == stable_fingerprint), None)
            if profile is not None:
                profile.project_fingerprint = stable_fingerprint
                device.project_fingerprint = stable_fingerprint
                return profile

        # Final safe recovery: if the operator added/removed unrelated Inputs, the
        # whole-project fingerprint changes even though every linked target is still
        # present. Reuse a profile only when the target set identifies one profile
        # unambiguously. Do not rewrite its primary fingerprint here, because another
        # active Agent may legitimately use the original project variant.
        return self._compatible_mapping_profile_for_device(session, device)

    def start_auto_refresh(self) -> None:
        """Start the server-side SQL → Mapping → vMix refresh scheduler."""
        if self._auto_refresh_task is None or self._auto_refresh_task.done():
            self._auto_refresh_task = asyncio.create_task(self._auto_refresh_loop(), name="hockey-mapping-auto-refresh")

    async def stop_auto_refresh(self) -> None:
        task = self._auto_refresh_task
        self._auto_refresh_task = None
        if task is None:
            return
        task.cancel()
        try:
            await task
        except asyncio.CancelledError:
            pass

    async def _auto_refresh_loop(self) -> None:
        while True:
            try:
                await self.auto_refresh_once()
                self._auto_refresh_last_error = ""
            except asyncio.CancelledError:
                raise
            except Exception as error:
                # The scheduler must never take the HTTP/WebSocket server down. A broken
                # source is isolated to its next tick and can be fixed from the admin UI.
                self._auto_refresh_last_error = str(error)[:500]
            await asyncio.sleep(0.25)

    async def auto_refresh_once(self) -> dict[str, Any]:
        """Apply due auto-refresh SQL sources to live devices, sending only changed values."""
        async with self._lock:
            live_ids = set(self._live.keys())
        if not live_ids:
            return {"ok": True, "devices": 0, "sources": 0, "applied": 0}

        with self.database.session() as session:
            source_rows = list(session.scalars(
                select(MappingSqlDataSource).where(and_(
                    MappingSqlDataSource.enabled.is_(True),
                    MappingSqlDataSource.auto_refresh_enabled.is_(True),
                ))
            ))
            source_intervals = {
                str(row.code): max(1.0, min(3600.0, float(int(row.refresh_interval_ms or 1000)) / 1000.0))
                for row in source_rows
            }
            if not source_intervals:
                return {"ok": True, "devices": 0, "sources": 0, "applied": 0}

            devices = list(session.scalars(
                select(VmixDevice).where(and_(
                    VmixDevice.device_uuid.in_(live_ids),
                    VmixDevice.is_active_for_account.is_(True),
                    VmixDevice.vmix_connected.is_(True),
                ))
            ))
            jobs: list[tuple[str, set[str]]] = []
            loop_now = asyncio.get_running_loop().time()
            for device in devices:
                if not device.current_assignment_key or not device.current_match_external_id or not device.project_fingerprint:
                    continue
                profile = self._active_mapping_profile_for_device(session, device)
                if profile is None:
                    continue
                data_keys = list(session.scalars(
                    select(VmixMappingField.data_key).where(and_(
                        VmixMappingField.profile_id == profile.id,
                        VmixMappingField.enabled.is_(True),
                    ))
                ))
                used_codes = {self._mapping_source_code(key) for key in data_keys}
                due: set[str] = set()
                for code in used_codes.intersection(source_intervals):
                    schedule_key = (str(device.device_uuid), code)
                    if loop_now + 1e-9 < self._auto_refresh_next.get(schedule_key, 0.0):
                        continue
                    due.add(code)
                    self._auto_refresh_next[schedule_key] = loop_now + source_intervals[code]
                if due:
                    jobs.append((str(device.device_uuid), due))

        if not jobs:
            return {"ok": True, "devices": 0, "sources": 0, "applied": 0}

        raw_results = await asyncio.gather(*(
            self.apply_mapping_to_device(
                device_id, reason="sql_auto_refresh", source_codes=source_codes, only_changed=True
            )
            for device_id, source_codes in jobs
        ), return_exceptions=True)
        applied = 0
        errors: list[str] = []
        for value in raw_results:
            if isinstance(value, Exception):
                errors.append(str(value)[:300])
            elif isinstance(value, dict):
                applied += int(value.get("applied") or 0)
                errors.extend(str(item)[:300] for item in (value.get("errors") or []))
        return {
            "ok": not errors,
            "devices": len(jobs),
            "sources": sum(len(codes) for _, codes in jobs),
            "applied": applied,
            "errors": errors,
        }

    async def register(self, websocket: WebSocket, hello: dict[str, Any]) -> dict[str, Any]:
        device_id = self.normalise_device_id(hello.get("device_id"))
        device_secret = str(hello.get("device_secret") or "").strip()
        if len(device_secret) < 24:
            raise PermissionError("device_secret слишком короткий")
        now = _utcnow()
        secret_hash = _secret_hash(device_secret)
        vmix = hello.get("vmix") if isinstance(hello.get("vmix"), dict) else {}
        client = websocket.client
        client_ip = client.host if client else ""

        with self.database.session() as session:
            row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if row is None:
                row = VmixDevice(
                    device_uuid=device_id,
                    device_secret_hash=secret_hash,
                    name=str(hello.get("device_name") or hello.get("hostname") or device_id)[:200],
                    hostname=str(hello.get("hostname") or "")[:200],
                    agent_version=str(hello.get("agent_version") or "")[:64],
                    first_seen_at=now,
                    last_seen_at=now,
                    last_ip=client_ip[:128],
                    vmix_connected=bool(vmix.get("connected")),
                    vmix_version=str(vmix.get("version") or "")[:64],
                    vmix_url=str(vmix.get("url") or "")[:500],
                )
                session.add(row)
                session.flush()
            elif not hmac.compare_digest(row.device_secret_hash, secret_hash):
                raise PermissionError("Этот device_id уже зарегистрирован с другим ключом")
            else:
                row.name = str(hello.get("device_name") or row.name or row.hostname or device_id)[:200]
                row.hostname = str(hello.get("hostname") or row.hostname or "")[:200]
                row.agent_version = str(hello.get("agent_version") or row.agent_version or "")[:64]
                row.last_seen_at = now
                row.last_ip = client_ip[:128]
                row.vmix_connected = bool(vmix.get("connected"))
                row.vmix_version = str(vmix.get("version") or row.vmix_version or "")[:64]
                row.vmix_url = str(vmix.get("url") or row.vmix_url or "")[:500]
                row.last_error = ""
            session.flush()
            payload = self._device_payload(row, viewer_user_id=row.wfl_user_id or "")

        async with self._lock:
            previous = self._live.get(device_id)
            self._live[device_id] = LiveAgent(websocket=websocket, connected_at=now)
        if previous is not None and previous.websocket is not websocket:
            try:
                await previous.websocket.close(code=4001, reason="Device reconnected")
            except Exception:
                pass

        return payload

    async def unregister(self, device_id: str, websocket: WebSocket) -> None:
        async with self._lock:
            current = self._live.get(device_id)
            if current is not None and current.websocket is websocket:
                self._live.pop(device_id, None)
        self._runtime_overlay_state.pop(device_id, None)
        self._runtime_overlay_authoritative[device_id] = False
        with self.database.session() as session:
            row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if row is not None:
                row.last_seen_at = _utcnow()

    async def receive_status(self, device_id: str, message: dict[str, Any]) -> None:
        vmix = message.get("vmix") if isinstance(message.get("vmix"), dict) else {}
        now = _utcnow()
        with self.database.session() as session:
            row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if row is None:
                return
            row.last_seen_at = now
            if "connected" in vmix:
                row.vmix_connected = bool(vmix.get("connected"))
                if not row.vmix_connected:
                    self._runtime_overlay_state.pop(device_id, None)
                    self._runtime_overlay_authoritative[device_id] = False
            if vmix.get("version") is not None:
                row.vmix_version = str(vmix.get("version") or "")[:64]
            if vmix.get("url") is not None:
                row.vmix_url = str(vmix.get("url") or "")[:500]
            if message.get("error") is not None:
                row.last_error = str(message.get("error") or "")[:1000]

    async def receive_overlay_state(self, device_id: str, message: dict[str, Any]) -> None:
        raw_overlays = message.get("overlays") if isinstance(message.get("overlays"), dict) else {}
        next_state: dict[str, dict[str, str]] = {}
        for layer in ("1", "2", "3", "4", "5", "6", "7", "8"):
            raw = raw_overlays.get(layer)
            if raw is None:
                raw = raw_overlays.get(int(layer))
            if not isinstance(raw, dict):
                continue
            entry = {
                "input": str(raw.get("input") or "").strip(),
                "input_number": str(raw.get("input_number") or "").strip(),
                "input_key": str(raw.get("input_key") or "").strip(),
                "input_title": str(raw.get("input_title") or "").strip(),
                "sequence_id": "",
                "sequence_name": "",
                "button_id": "",
                "source": "agent_tally",
            }
            if not any(entry.get(key) for key in ("input", "input_number", "input_key", "input_title")):
                continue
            next_state[layer] = entry

        vmix_connected = bool(message.get("vmix_connected", True))
        if vmix_connected:
            self._runtime_overlay_state[device_id] = next_state
            self._runtime_overlay_authoritative[device_id] = True
            self._runtime_overlay_observed_at[device_id] = str(message.get("observed_at") or _utcnow().isoformat())[:80]
        else:
            self._runtime_overlay_state.pop(device_id, None)
            self._runtime_overlay_authoritative[device_id] = False
            self._runtime_overlay_observed_at[device_id] = str(message.get("observed_at") or _utcnow().isoformat())[:80]

        now = _utcnow()
        with self.database.session() as session:
            row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if row is not None:
                row.last_seen_at = now
                row.vmix_connected = vmix_connected

    async def send(self, device_id: str, payload: dict[str, Any]) -> bool:
        async with self._lock:
            live = self._live.get(device_id)
        if live is None:
            return False
        try:
            await live.websocket.send_json(payload)
            return True
        except Exception:
            async with self._lock:
                if self._live.get(device_id) is live:
                    self._live.pop(device_id, None)
            return False

    def _device_vmix_send_lock(self, device_id: str) -> asyncio.Lock:
        lock = self._vmix_send_locks.get(device_id)
        if lock is None:
            lock = asyncio.Lock()
            self._vmix_send_locks[device_id] = lock
        return lock

    def _device_mapping_apply_lock(self, device_id: str) -> asyncio.Lock:
        lock = self._mapping_apply_locks.get(device_id)
        if lock is None:
            lock = asyncio.Lock()
            self._mapping_apply_locks[device_id] = lock
        return lock

    def _device_vmix_shortcut_send_lock(self, device_id: str) -> asyncio.Lock:
        lock = self._vmix_shortcut_send_locks.get(device_id)
        if lock is None:
            lock = asyncio.Lock()
            self._vmix_shortcut_send_locks[device_id] = lock
        return lock

    @staticmethod
    def _mapping_batch_chunks(entries: list[dict[str, Any]]) -> list[list[tuple[int, dict[str, Any]]]]:
        """Group Mapping commands by vMix Input, then cap packet count and bytes.

        The returned tuples keep the original index so ACKs can be mapped back to
        the original Mapping links even though commands are grouped by Input.
        """
        groups: dict[str, list[tuple[int, dict[str, Any]]]] = {}
        for index, entry in enumerate(entries):
            command = entry.get("command") if isinstance(entry, dict) else None
            input_ref = str((command or {}).get("Input") or "").strip()
            groups.setdefault(input_ref, []).append((index, entry))

        chunks: list[list[tuple[int, dict[str, Any]]]] = []
        for group in groups.values():
            current: list[tuple[int, dict[str, Any]]] = []
            current_bytes = 0
            for indexed_entry in group:
                command = indexed_entry[1].get("command") or {}
                try:
                    command_bytes = len(json.dumps(command, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) + 96
                except Exception:
                    command_bytes = 512
                if current and (
                    len(current) >= MAPPING_BATCH_MAX_COMMANDS
                    or current_bytes + command_bytes > MAPPING_BATCH_MAX_BYTES
                ):
                    chunks.append(current)
                    current = []
                    current_bytes = 0
                current.append(indexed_entry)
                current_bytes += command_bytes
            if current:
                chunks.append(current)
        return chunks

    async def _send_mapping_entries_resilient(
        self,
        device_id: str,
        *,
        assignment_id: str,
        match_id: str,
        entries: list[dict[str, Any]],
        use_batch: bool,
    ) -> dict[str, Any]:
        """BUILD113: send Mapping as simple ordered vmix.command frames, no ACK waits.

        Mapping used to use vmix.batch + ACK + recursive retries. In the field this
        could stall for a long time when ACK delivery/state drifted, even though
        ordinary interactive commands were healthy. Before every Mapping push we
        re-send the authoritative match.assign for this device, then deliver each
        concrete vMix command in strict WebSocket order. Success here means the
        frame reached the live Agent socket; Mapping never blocks on command.ack.
        """
        outcomes: list[dict[str, Any] | None] = [None] * len(entries)
        input_groups = len({
            str(((entry.get("command") or {}).get("Input") or "")).strip()
            for entry in entries
        })
        stats = {
            "chunks_total": 0,
            "chunks_ok": 0,
            "chunks_failed": 0,
            "packets_sent": 0,
            "retries": 0,
            "fallback_commands": 0,
            "commands_sent": 0,
            "input_groups": input_groups,
            "ack_waited": False,
            "transport": "sequential_no_ack",
        }

        # Re-assert the current assignment immediately before Mapping. This heals
        # stale Agent-side match/assignment state and prevents match_mismatch /
        # assignment_mismatch from an earlier browser/session switch.
        assignment_payload: dict[str, Any] | None = None
        with self.database.session() as session:
            device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if device is not None:
                assignment = session.scalar(
                    select(VmixAssignment).where(
                        and_(
                            VmixAssignment.device_id == device.id,
                            VmixAssignment.assignment_key == assignment_id,
                            VmixAssignment.active.is_(True),
                        )
                    )
                )
                if assignment is not None and str(assignment.game_external_id or "") == str(match_id or ""):
                    assignment_payload = self._assignment_payload(assignment, device_id=device_id)

        if assignment_payload is not None:
            assignment_delivered = await self.send(
                device_id,
                {"type": "match.assign", "protocol": AGENT_PROTOCOL_VERSION, **assignment_payload},
            )
            if not assignment_delivered:
                return {
                    "results": [{"ok": False, "reason": "Agent сейчас offline"} for _ in entries],
                    **stats,
                    "assignment_refreshed": False,
                }
            # Yield once so the Agent read loop can process match.assign before the
            # first vMix command while preserving WebSocket frame order.
            await asyncio.sleep(0)

        async with self._device_mapping_apply_lock(device_id):
            for index, entry in enumerate(entries):
                command = entry.get("command") if isinstance(entry, dict) else None
                if not isinstance(command, dict) or not str(command.get("Function") or "").strip():
                    outcomes[index] = {"ok": False, "reason": "invalid_mapping_command"}
                    continue
                request_id = secrets.token_urlsafe(12)
                delivered = await self.send(
                    device_id,
                    {
                        "type": "vmix.command",
                        "protocol": AGENT_PROTOCOL_VERSION,
                        "request_id": request_id,
                        "device_id": device_id,
                        "assignment_id": assignment_id,
                        "match_id": match_id,
                        "command": command,
                    },
                )
                if not delivered:
                    outcomes[index] = {
                        "ok": False,
                        "reason": "Agent сейчас offline",
                        "delivery": "agent-websocket",
                        "ack_waited": False,
                    }
                    # A failed WebSocket send means the connection is gone; there
                    # is no value in attempting hundreds more fields.
                    for tail in range(index + 1, len(entries)):
                        outcomes[tail] = {
                            "ok": False,
                            "reason": "Agent сейчас offline",
                            "delivery": "agent-websocket",
                            "ack_waited": False,
                        }
                    break
                stats["commands_sent"] += 1
                outcomes[index] = {
                    "ok": True,
                    "delivery": "agent-websocket",
                    "ack_waited": False,
                    "request_id": request_id,
                }

        return {
            "results": [
                item if isinstance(item, dict) else {"ok": False, "reason": "mapping_transport_not_sent"}
                for item in outcomes
            ],
            **stats,
            "assignment_refreshed": assignment_payload is not None,
        }

    async def receive_command_ack(self, device_id: str, message: dict[str, Any]) -> None:
        request_id = str(message.get("request_id") or "").strip()
        if not request_id:
            return
        pending = self._pending_commands.get(request_id)
        if pending is None:
            return
        expected_device_id, future = pending
        if expected_device_id != device_id or future.done():
            return
        payload = dict(message)
        payload["device_id"] = device_id
        future.set_result(payload)

    async def _send_vmix_command_unlocked(
        self,
        device_id: str,
        *,
        assignment_id: str,
        match_id: str,
        command: dict[str, Any],
        timeout: float = 5.0,
    ) -> dict[str, Any]:
        request_id = secrets.token_urlsafe(12)
        loop = asyncio.get_running_loop()
        future: asyncio.Future[dict[str, Any]] = loop.create_future()
        self._pending_commands[request_id] = (device_id, future)
        try:
            delivered = await self.send(
                device_id,
                {
                    "type": "vmix.command",
                    "protocol": AGENT_PROTOCOL_VERSION,
                    "request_id": request_id,
                    "device_id": device_id,
                    "assignment_id": assignment_id,
                    "match_id": match_id,
                    "command": command,
                },
            )
            if not delivered:
                raise HTTPException(status_code=409, detail="Agent сейчас offline")
            try:
                ack = await asyncio.wait_for(future, timeout=timeout)
            except asyncio.TimeoutError as error:
                raise HTTPException(status_code=504, detail="Agent не подтвердил команду vMix") from error
            return ack
        finally:
            pending = self._pending_commands.pop(request_id, None)
            if pending is not None:
                _, pending_future = pending
                if not pending_future.done():
                    pending_future.cancel()

    async def send_vmix_command(
        self,
        device_id: str,
        *,
        assignment_id: str,
        match_id: str,
        command: dict[str, Any],
        timeout: float = 5.0,
    ) -> dict[str, Any]:
        async with self._device_vmix_send_lock(device_id):
            return await self._send_vmix_command_unlocked(
                device_id, assignment_id=assignment_id, match_id=match_id, command=command, timeout=timeout
            )

    async def _send_vmix_batch_unlocked(
        self,
        device_id: str,
        *,
        assignment_id: str,
        match_id: str,
        commands: list[dict[str, Any]],
        timeout: float = 8.0,
    ) -> dict[str, Any]:
        """Send many concrete vMix commands in one WebSocket frame and wait for one ACK."""
        request_id = secrets.token_urlsafe(12)
        loop = asyncio.get_running_loop()
        future: asyncio.Future[dict[str, Any]] = loop.create_future()
        self._pending_commands[request_id] = (device_id, future)
        try:
            delivered = await self.send(
                device_id,
                {
                    "type": "vmix.batch",
                    "protocol": AGENT_PROTOCOL_VERSION,
                    "request_id": request_id,
                    "device_id": device_id,
                    "assignment_id": assignment_id,
                    "match_id": match_id,
                    "commands": commands,
                },
            )
            if not delivered:
                raise HTTPException(status_code=409, detail="Agent сейчас offline")
            try:
                return await asyncio.wait_for(future, timeout=timeout)
            except asyncio.TimeoutError as error:
                raise HTTPException(status_code=504, detail="Agent не подтвердил пакет команд vMix") from error
        finally:
            pending = self._pending_commands.pop(request_id, None)
            if pending is not None:
                _, pending_future = pending
                if not pending_future.done():
                    pending_future.cancel()

    async def send_vmix_batch(
        self,
        device_id: str,
        *,
        assignment_id: str,
        match_id: str,
        commands: list[dict[str, Any]],
        timeout: float = 8.0,
    ) -> dict[str, Any]:
        async with self._device_vmix_send_lock(device_id):
            return await self._send_vmix_batch_unlocked(
                device_id, assignment_id=assignment_id, match_id=match_id, commands=commands, timeout=timeout
            )

    async def test_set_text(
        self,
        device_id: str,
        user: HockeyUser,
        *,
        input_ref: str,
        selected_name: str,
        value: str,
    ) -> dict[str, Any]:
        device_id = self.normalise_device_id(device_id)
        input_ref = str(input_ref or "").strip()
        selected_name = str(selected_name or "").strip()
        if not input_ref:
            raise HTTPException(status_code=422, detail="Укажите Input (имя, номер или key)")
        if not selected_name:
            raise HTTPException(status_code=422, detail="Укажите имя текстового поля vMix")
        if len(input_ref) > 300 or len(selected_name) > 300 or len(value) > 4000:
            raise HTTPException(status_code=422, detail="Слишком длинные параметры тестовой команды")

        with self.database.session() as session:
            device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if device is None or device.wfl_user_id != user.id:
                raise HTTPException(status_code=404, detail="Устройство не прикреплено к вашему аккаунту")
            if not device.is_active_for_account:
                raise HTTPException(status_code=409, detail="Включите для этого Agent режим «Получать данные»")
            assignment_id = str(device.current_assignment_key or "")
            match_id = str(device.current_match_external_id or "")
            if not assignment_id or not match_id:
                raise HTTPException(status_code=409, detail="Сначала выберите матч в web-интерфейсе")

        ack = await self.send_vmix_command(
            device_id,
            assignment_id=assignment_id,
            match_id=match_id,
            command={
                "Function": "SetText",
                "Input": input_ref,
                "SelectedName": selected_name,
                "Value": value,
            },
        )
        if not bool(ack.get("ok")):
            reason = str(ack.get("reason") or ack.get("error") or "vMix отклонил команду")
            raise HTTPException(status_code=502, detail=f"Команда дошла до agent, но не выполнена: {reason}")
        return {
            "ok": True,
            "device_id": device_id,
            "assignment_id": assignment_id,
            "match_id": match_id,
            "function": "SetText",
            "input": input_ref,
            "selected_name": selected_name,
            "value": value,
            "agent_ack": ack,
        }

    @staticmethod
    def _score_int(value: Any, fallback: int = 0) -> int:
        try:
            return max(0, int(float(str(value if value is not None else fallback).strip())))
        except (TypeError, ValueError):
            return max(0, int(fallback or 0))

    def _score_source_settings(self) -> tuple[str, str]:
        try:
            settings = self.mapping_data.settings.public() if self.mapping_data.settings is not None else {}
        except Exception:
            settings = {}
        return (
            str(settings.get("score_external_home_key") or "").strip(),
            str(settings.get("score_external_away_key") or "").strip(),
        )

    def _persist_score_external_snapshot(
        self,
        game_external_id: str,
        *,
        home: int,
        away: int,
        source: str,
    ) -> None:
        game_external_id = str(game_external_id or "").strip()
        if not game_external_id:
            return
        with self.database.session() as session:
            row = session.scalar(select(GameControlState).where(GameControlState.game_external_id == game_external_id))
            if row is None:
                row = GameControlState(game_external_id=game_external_id)
                session.add(row)
                session.flush()
            try:
                values = json.loads(str(row.runtime_values_json or "{}"))
            except (TypeError, ValueError, json.JSONDecodeError):
                values = {}
            if not isinstance(values, dict):
                values = {}
            values["score.external_valid"] = "1"
            values["score.external_home"] = str(max(0, int(home)))
            values["score.external_away"] = str(max(0, int(away)))
            values["score.external_source"] = str(source or "game")[:160]
            row.runtime_values_json = json.dumps(values, ensure_ascii=False, separators=(",", ":"))
            row.updated_at = _utcnow()

    async def apply_mapping_to_device(
        self,
        device_id: str,
        *,
        reason: str = "manual",
        source_codes: set[str] | None = None,
        only_changed: bool = False,
        extra_context: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        """Resolve the active mapping against the device's current match and push values to vMix.

        Mapping configuration stays server-side. The Agent receives only concrete vMix commands,
        so it never needs database access or knowledge of SQL/context keys.
        """
        device_id = self.normalise_device_id(device_id)
        with self.database.session() as session:
            device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if device is None:
                return {"ok": False, "reason": "device_not_found", "device_id": device_id}
            if not device.wfl_user_id:
                return {"ok": False, "reason": "device_not_paired", "device_id": device_id}
            assignment_id = str(device.current_assignment_key or "")
            match_id = str(device.current_match_external_id or "")
            fingerprint = str(device.project_fingerprint or "")
            vmix_connected = bool(device.vmix_connected)
            login = str(device.login_snapshot or "")
            user_id = str(device.wfl_user_id or "")
            agent_version = str(device.agent_version or "")
            if not assignment_id or not match_id:
                return {"ok": False, "reason": "no_match_assignment", "device_id": device_id}
            if not fingerprint:
                if agent_version and not self._agent_supports_mapping_inventory(agent_version):
                    return {
                        "ok": False,
                        "reason": "agent_mapping_unsupported",
                        "device_id": device_id,
                        "agent_version": agent_version,
                        "required_agent_version": "1.3.0",
                    }
                return {"ok": False, "reason": "no_project_inventory", "device_id": device_id}
            if not vmix_connected:
                return {"ok": False, "reason": "vmix_not_connected", "device_id": device_id}
            assignment = session.scalar(
                select(VmixAssignment).where(
                    and_(
                        VmixAssignment.device_id == device.id,
                        VmixAssignment.assignment_key == assignment_id,
                        VmixAssignment.active.is_(True),
                    )
                )
            )
            tournament_id = str(assignment.tournament_external_id or "") if assignment is not None else ""
            preference = session.get(UserPreference, user_id)
            mapping_language = ""
            if preference is not None:
                preferred_vmix = str(preference.vmix_language or "").strip().lower()
                preferred_display = str(preference.display_language or "").strip().lower()
                if preferred_vmix in {"ru", "en"}:
                    mapping_language = preferred_vmix
                elif preferred_display in {"ru", "en"}:
                    mapping_language = preferred_display
            profile = self._active_mapping_profile_for_device(session, device)
            if profile is None:
                return {"ok": False, "reason": "mapping_missing", "device_id": device_id}
            fields = list(
                session.scalars(
                    select(VmixMappingField)
                    .where(and_(VmixMappingField.profile_id == profile.id, VmixMappingField.enabled.is_(True)))
                    .order_by(VmixMappingField.sort_order, VmixMappingField.id)
                )
            )
            if source_codes is not None:
                wanted_sources = {str(code) for code in source_codes}
                fields = [field for field in fields if self._mapping_source_code(field.data_key) in wanted_sources]
            profile_id = profile.id
            profile_name = profile.name
            profile_version = profile.version

        user = HockeyUser(id=user_id, login=login or user_id, display_name=login or user_id)
        supplied_context = {
            "game_id": match_id,
            "tournament_id": tournament_id,
            "device_id": device_id,
            "assignment_id": assignment_id,
        }
        if isinstance(extra_context, dict):
            for raw_key, raw_value in list(extra_context.items())[:32]:
                key = re.sub(r"[^A-Za-z0-9_.:-]+", "_", str(raw_key or "").strip())[:96]
                if not key or raw_value is None:
                    continue
                supplied_context[key] = str(raw_value)[:8000]
        if mapping_language:
            supplied_context["ui_language"] = mapping_language
        catalog_source_codes = None if source_codes is None else {str(code) for code in source_codes}
        score_home_key, score_away_key = self._score_source_settings()
        if catalog_source_codes is not None and "score" in catalog_source_codes:
            for score_key in (score_home_key, score_away_key):
                dependency_code = self._mapping_source_code(score_key)
                if dependency_code and dependency_code not in {"score", "context"}:
                    catalog_source_codes.add(dependency_code)
        if catalog_source_codes is not None:
            for field in fields:
                rule = _mapping_rule_payload(getattr(field, "rule_json", "{}"))
                if not bool(rule.get("enabled")):
                    continue
                for dependency in (rule.get("left_key"), rule.get("right_key") if str(rule.get("right_mode") or "field").lower() != "value" else ""):
                    code = self._mapping_source_code(dependency)
                    if code:
                        catalog_source_codes.add(code)
        catalog = (
            self.mapping_data.data_catalog(user, supplied_context, source_codes=catalog_source_codes)
            if catalog_source_codes is not None
            else self.mapping_data.data_catalog(user, supplied_context)
        )
        catalog_items = [item for item in (catalog.get("items") or []) if isinstance(item, dict) and item.get("key")]
        by_key = {str(item.get("key") or ""): item for item in catalog_items}
        # ``score`` is a natural name for an operator-created SQL source. Keep the
        # built-in LIVE score namespace authoritative even if such a source exists.
        for item in catalog_items:
            key = str(item.get("key") or "")
            if key.startswith("score.") and str(item.get("source_code") or "") == "live_control":
                by_key[key] = item

        def external_score_item(key: str) -> dict[str, Any] | None:
            key = str(key or "").strip()
            if not key:
                return None
            candidates = [
                item for item in catalog_items
                if str(item.get("key") or "") == key and str(item.get("source_code") or "") != "live_control"
            ]
            return candidates[-1] if candidates else (by_key.get(key) if not key.startswith("score.") else None)

        score_snapshot = None
        if source_codes is None or "score" in {str(code) for code in source_codes}:
            fallback_home = self._score_int((by_key.get("score.external_home") or {}).get("value"), 0)
            fallback_away = self._score_int((by_key.get("score.external_away") or {}).get("value"), 0)
            home_item = external_score_item(score_home_key)
            away_item = external_score_item(score_away_key)
            resolved_home = self._score_int(home_item.get("value"), fallback_home) if isinstance(home_item, dict) else fallback_home
            resolved_away = self._score_int(away_item.get("value"), fallback_away) if isinstance(away_item, dict) else fallback_away
            source_label = "mapping_sql" if (score_home_key and score_away_key and isinstance(home_item, dict) and isinstance(away_item, dict)) else "game"
            self._persist_score_external_snapshot(match_id, home=resolved_home, away=resolved_away, source=source_label)
            if "score.external_home" in by_key:
                by_key["score.external_home"]["value"] = resolved_home
            if "score.external_away" in by_key:
                by_key["score.external_away"]["value"] = resolved_away
            manual = str((by_key.get("score.manual") or {}).get("value") or "0").strip().lower() in {"1", "true", "yes", "on"}
            if not manual:
                if "score.home" in by_key:
                    by_key["score.home"]["value"] = resolved_home
                if "score.away" in by_key:
                    by_key["score.away"]["value"] = resolved_away
            score_snapshot = {
                "home": resolved_home, "away": resolved_away, "manual": manual,
                "source": source_label, "home_key": score_home_key, "away_key": score_away_key,
            }

        result: dict[str, Any] = {
            "ok": True,
            "reason": reason,
            "device_id": device_id,
            "assignment_id": assignment_id,
            "match_id": match_id,
            "profile_id": profile_id,
            "profile_name": profile_name,
            "profile_version": profile_version,
            "score_snapshot": score_snapshot,
            "total": len(fields),
            "applied": 0,
            "rules_total": 0,
            "rules_applied": 0,
            "unchanged": 0,
            "rules_unchanged": 0,
            "rule_missing": [],
            "source_codes": sorted(source_codes) if source_codes is not None else [],
            "missing": [],
            "errors": [],
            "transport": "batch" if self._agent_supports_batch(agent_version) else "legacy",
        }
        pending_entries: list[dict[str, Any]] = []
        for field in fields:
            data_key = str(field.data_key or "")
            item = by_key.get(data_key)
            if item is None:
                result["missing"].append(data_key)
                continue
            raw_value = item.get("value", "")
            value = "" if raw_value is None else str(raw_value)
            input_ref = str(field.vmix_input_key or field.vmix_input_title or field.vmix_input_number or "").strip()
            selected_name = str(field.vmix_field or "").strip()
            if not input_ref or not selected_name:
                result["errors"].append({"key": data_key, "reason": "missing_vmix_target"})
                continue
            field_type = str(field.field_type or "text").lower()
            function = _mapping_value_function(field_type)
            cache_key = (device_id, assignment_id, int(profile_id), input_ref, selected_name, function, data_key)
            if only_changed and self._mapping_value_cache.get(cache_key) == value:
                result["unchanged"] += 1
            else:
                pending_entries.append({
                    "entry_kind": "value",
                    "data_key": data_key,
                    "value": value,
                    "cache_key": cache_key,
                    "command": {
                        "Function": function,
                        "Input": input_ref,
                        "SelectedName": selected_name,
                        "Value": value,
                    },
                })

            rule = _mapping_rule_payload(getattr(field, "rule_json", "{}"))
            rule_command, rule_reason = _mapping_rule_command(
                rule, field_type=field_type, input_ref=input_ref, selected_name=selected_name,
                default_data_key=data_key, by_key=by_key,
            )
            if rule_reason.startswith("missing_"):
                result["rule_missing"].append({"key": data_key, "reason": rule_reason})
            elif rule_reason:
                result["errors"].append({"key": data_key, "reason": f"rule:{rule_reason}"})
            if rule_command is not None:
                result["rules_total"] += 1
                rule_function = str(rule_command.get("Function") or "")
                rule_value = str(rule_command.get("Value") or rule_function)
                rule_cache_key = (device_id, assignment_id, int(profile_id), input_ref, selected_name, rule_function, f"rule:{data_key}")
                if only_changed and self._mapping_value_cache.get(rule_cache_key) == rule_value:
                    result["rules_unchanged"] += 1
                else:
                    pending_entries.append({
                        "entry_kind": "rule",
                        "data_key": data_key,
                        "value": rule_value,
                        "cache_key": rule_cache_key,
                        "command": rule_command,
                    })

        result["total_commands"] = len(pending_entries)
        if not pending_entries:
            result["ok"] = not result["errors"]
            return result

        use_batch = self._agent_supports_batch(agent_version)
        result["transport"] = "sequential_no_ack"
        transport = await self._send_mapping_entries_resilient(
            device_id,
            assignment_id=assignment_id,
            match_id=match_id,
            entries=pending_entries,
            use_batch=use_batch,
        )
        result["batch_chunks_total"] = int(transport.get("chunks_total") or 0)
        result["batch_chunks_applied"] = int(transport.get("chunks_ok") or 0)
        result["batch_chunks_failed"] = int(transport.get("chunks_failed") or 0)
        result["batch_packets_sent"] = int(transport.get("packets_sent") or 0)
        result["batch_retries"] = int(transport.get("retries") or 0)
        result["fallback_commands"] = int(transport.get("fallback_commands") or 0)
        result["mapping_commands_sent"] = int(transport.get("commands_sent") or 0)
        result["mapping_ack_waited"] = bool(transport.get("ack_waited", False))
        result["assignment_refreshed"] = bool(transport.get("assignment_refreshed", False))
        result["input_groups"] = int(transport.get("input_groups") or 0)
        result["batch_max_commands"] = MAPPING_BATCH_MAX_COMMANDS

        ack_results = transport.get("results") if isinstance(transport.get("results"), list) else []
        for index, entry in enumerate(pending_entries):
            item_ack = ack_results[index] if index < len(ack_results) and isinstance(ack_results[index], dict) else {}
            if bool(item_ack.get("ok")):
                if entry.get("entry_kind") == "rule":
                    result["rules_applied"] += 1
                else:
                    result["applied"] += 1
                self._mapping_value_cache[entry["cache_key"]] = entry["value"]
            else:
                result["errors"].append({
                    "key": entry["data_key"],
                    "input": str((entry.get("command") or {}).get("Input") or ""),
                    "field": str((entry.get("command") or {}).get("SelectedName") or ""),
                    "reason": str(item_ack.get("reason") or item_ack.get("error") or "mapping_transport_error"),
                })
        result["ok"] = not result["errors"]
        return result

    async def apply_mapping_for_user(
        self,
        user: HockeyUser,
        *,
        reason: str = "context_changed",
        device_id: str = "",
        session_token: str = "",
        only_changed: bool = False,
    ) -> dict[str, Any] | None:
        """Apply Mapping to exactly one browser/session Agent, never fan out by account."""
        requested = str(device_id or "").strip()
        token = str(session_token or "").strip()
        with self.database.session() as session:
            if not requested and token:
                operator = session.scalar(select(OperatorSession).where(and_(
                    OperatorSession.session_token == token,
                    OperatorSession.wfl_user_id == user.id,
                    OperatorSession.status == "active",
                )))
                if operator is not None:
                    requested = str(operator.vmix_device_uuid or "").strip()
            if requested:
                requested = self.normalise_device_id(requested)
                device = session.scalar(select(VmixDevice).where(and_(
                    VmixDevice.device_uuid == requested,
                    VmixDevice.wfl_user_id == user.id,
                    VmixDevice.is_active_for_account.is_(True),
                )))
                if device is None:
                    return {"ok": False, "reason": "device_not_available", "device_id": requested}
                target = device.device_uuid
            else:
                devices = list(session.scalars(select(VmixDevice).where(and_(
                    VmixDevice.wfl_user_id == user.id,
                    VmixDevice.is_active_for_account.is_(True),
                )).order_by(desc(VmixDevice.last_seen_at))))
                if not devices:
                    return None
                if len(devices) != 1:
                    return {"ok": False, "reason": "device_selection_required", "total_devices": len(devices)}
                target = devices[0].device_uuid
        return await self.apply_mapping_to_device(target, reason=reason, only_changed=only_changed)

    async def apply_mapping_to_all_active_devices(self, *, reason: str = "global_runtime_changed") -> dict[str, Any]:
        """Admin-only explicit fan-out used for truly global maintenance actions."""
        with self.database.session() as session:
            device_ids = [
                str(value)
                for value in session.scalars(
                    select(VmixDevice.device_uuid)
                    .where(and_(
                        VmixDevice.wfl_user_id.is_not(None),
                        VmixDevice.is_active_for_account.is_(True),
                        VmixDevice.vmix_connected.is_(True),
                    ))
                    .order_by(VmixDevice.id)
                )
                if str(value or "").strip()
            ]
        if not device_ids:
            return {"ok": True, "reason": reason, "total_devices": 0, "applied_devices": 0, "devices": []}

        async def apply_one(target_device_id: str) -> dict[str, Any]:
            try:
                result = await self.apply_mapping_to_device(target_device_id, reason=reason)
                return {"device_id": target_device_id, **result}
            except Exception as error:
                return {"ok": False, "device_id": target_device_id, "reason": str(error)[:500]}

        results = list(await asyncio.gather(*(apply_one(target_device_id) for target_device_id in device_ids)))
        applied_devices = sum(1 for item in results if bool(item.get("ok")))
        return {
            "ok": applied_devices == len(device_ids),
            "reason": reason,
            "total_devices": len(device_ids),
            "applied_devices": applied_devices,
            "devices": results,
        }

    async def _run_vmix_sequence_on_device(
        self,
        device_id: str,
        assignment_id: str,
        match_id: str,
        commands: list[dict[str, Any]],
        *,
        timeout: float,
    ) -> dict[str, Any]:
        """Execute one sequence serially on one Agent."""
        results: list[dict[str, Any]] = []
        for index, raw in enumerate(commands):
            function = str(raw.get("Function") or raw.get("function") or "").strip()
            if not function:
                raise HTTPException(status_code=422, detail=f"Команда {index + 1}: не указана Function")
            command: dict[str, Any] = {"Function": function}
            for key, value in raw.items():
                if str(key).lower() == "function" or value is None:
                    continue
                if str(key).lower() != "value" and str(value) == "":
                    continue
                command[str(key)] = value
            ack = await self.send_vmix_command(
                device_id,
                assignment_id=assignment_id,
                match_id=match_id,
                command=command,
                timeout=timeout,
            )
            item = {
                "index": index,
                "function": function,
                "ok": bool(ack.get("ok")),
                "reason": str(ack.get("reason") or ack.get("error") or ""),
            }
            results.append(item)
            if not item["ok"]:
                raise HTTPException(
                    status_code=502,
                    detail={
                        "message": f"vMix command failed: {function}",
                        "index": index,
                        "results": results,
                    },
                )
        return {
            "ok": True,
            "device_id": device_id,
            "match_id": match_id,
            "assignment_id": assignment_id,
            "applied": len(results),
            "results": results,
        }

    async def run_vmix_sequence_for_user(
        self,
        user: HockeyUser,
        commands: list[dict[str, Any]],
        *,
        device_id: str = "",
        session_token: str = "",
        sequence_id: str = "",
        sequence_name: str = "",
        button_id: str = "",
        delivery_mode: str = "",
        timeout: float = 4.0,
    ) -> dict[str, Any]:
        """Execute commands on exactly one Agent bound to this browser/match session."""
        requested_device_id = str(device_id or "").strip()
        session_token = str(session_token or "").strip()
        operator_game_id = ""
        operator_tournament_id = ""
        bound_device_id = ""

        with self.database.session() as session:
            if session_token:
                operator = session.scalar(
                    select(OperatorSession).where(
                        and_(
                            OperatorSession.session_token == session_token,
                            OperatorSession.wfl_user_id == user.id,
                            OperatorSession.status == "active",
                        )
                    )
                )
                if operator is None:
                    raise HTTPException(status_code=404, detail="Матчевая сессия не найдена")
                operator_game_id = str(operator.game_external_id or "")
                operator_tournament_id = str(operator.tournament_external_id or "")
                bound_device_id = str(operator.vmix_device_uuid or "").strip()

            target_device_id = requested_device_id or bound_device_id
            if target_device_id:
                target_device_id = self.normalise_device_id(target_device_id)
                device = session.scalar(
                    select(VmixDevice).where(
                        and_(
                            VmixDevice.device_uuid == target_device_id,
                            VmixDevice.wfl_user_id == user.id,
                            VmixDevice.is_active_for_account.is_(True),
                        )
                    )
                )
                if device is None:
                    raise HTTPException(status_code=409, detail="Выбранный Hockey Agent недоступен или отключён")
            else:
                candidates = list(session.scalars(
                    select(VmixDevice)
                    .where(and_(VmixDevice.wfl_user_id == user.id, VmixDevice.is_active_for_account.is_(True)))
                    .order_by(desc(VmixDevice.last_seen_at))
                ))
                if not candidates:
                    raise HTTPException(status_code=409, detail="Hockey Agent не выбран")
                if len(candidates) > 1:
                    raise HTTPException(status_code=409, detail="Для этой панели выберите конкретный Hockey Agent")
                device = candidates[0]
                target_device_id = device.device_uuid

            # An explicit browser device_id always wins. OperatorSession keeps a
            # fallback device only for legacy/single-panel clients; several browser
            # panels of the same account may legitimately target different Agents.
            assignment = session.scalar(
                select(VmixAssignment)
                .where(
                    and_(
                        VmixAssignment.device_id == device.id,
                        VmixAssignment.active.is_(True),
                    )
                )
                .order_by(desc(VmixAssignment.id))
            )
            assignment_matches = bool(
                assignment is not None
                and (not operator_game_id or assignment.game_external_id == operator_game_id)
                and (not session_token or not assignment.operator_session_token or assignment.operator_session_token == session_token)
            )
            need_assignment = not assignment_matches

        if need_assignment:
            if not operator_game_id:
                raise HTTPException(status_code=409, detail="Agent не привязан к текущему матчу")
            assigned = await self.assign_match(
                wfl_user_id=user.id,
                game_external_id=operator_game_id,
                tournament_external_id=operator_tournament_id,
                device_id=target_device_id,
                operator_session_token=session_token,
            )
            if not assigned:
                raise HTTPException(status_code=409, detail="Не удалось назначить матч выбранному Agent")

        with self.database.session() as session:
            device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == target_device_id))
            if device is None:
                raise HTTPException(status_code=404, detail="Agent не найден")
            assignment_id = str(device.current_assignment_key or "")
            match_id = str(device.current_match_external_id or "")
            agent_version = str(device.agent_version or "")
            if not assignment_id or not match_id:
                raise HTTPException(status_code=409, detail="Agent не привязан к текущему матчу")
            if operator_game_id and match_id != operator_game_id:
                raise HTTPException(status_code=409, detail="Agent назначен на другой матч")

        prepared_commands: list[dict[str, Any]] = []
        for index, raw in enumerate(commands):
            function = str(raw.get("Function") or raw.get("function") or "").strip()
            if not function:
                raise HTTPException(status_code=422, detail=f"Команда {index + 1}: не указана Function")
            command: dict[str, Any] = {"Function": function}
            for key, value in raw.items():
                if str(key).lower() == "function" or value is None:
                    continue
                if str(key).lower() != "value" and str(value) == "":
                    continue
                command[str(key)] = value
            prepared_commands.append(command)

        results: list[dict[str, Any]] = []
        # BUILD102: operator Shortcut/Quick-panel traffic is fire-and-forget after
        # the WebSocket frame has been handed to the connected Agent. We deliberately
        # do NOT wait for command.ack here. Agent ACK can arrive later and is ignored
        # for this transport. Mapping and generic runtime sync keep their existing ACK
        # semantics so diagnostics are still useful where exact field delivery matters.
        interactive_shortcut = bool(str(sequence_id or "").strip() or str(button_id or "").strip())
        delivery_mode = str(delivery_mode or "").strip().lower()
        fast_timer = delivery_mode in {"timer-fast", "timer-fast-ordered"}
        ordered_timer = delivery_mode == "timer-fast-ordered"
        no_ack_delivery = interactive_shortcut or fast_timer
        supports_batch = self._agent_supports_batch(agent_version) and len(prepared_commands) > 1
        use_batch = (not no_ack_delivery) and supports_batch
        if ordered_timer:
            transport = "timer-ordered-no-ack"
        elif fast_timer:
            transport = "timer-batch-no-ack" if supports_batch else "timer-no-ack"
        else:
            transport = "shortcut-no-ack" if interactive_shortcut else ("batch" if use_batch else "legacy")

        if no_ack_delivery:
            # BUILD106: realtime operator traffic bypasses Mapping's ACK queue. The new
            # timer-fast-ordered mode deliberately sends each timer command as its own WebSocket
            # frame in strict caller order and never waits for Agent/vMix ACK.
            # Legacy timer-fast keeps vmix.batch compatibility. The shared realtime lock prevents
            # interleaving with F-key traffic without reintroducing the slow Mapping lock.
            async with self._device_vmix_shortcut_send_lock(target_device_id):
                if fast_timer and supports_batch and not ordered_timer:
                    request_id = secrets.token_urlsafe(12)
                    delivered = await self.send(
                        target_device_id,
                        {
                            "type": "vmix.batch",
                            "protocol": AGENT_PROTOCOL_VERSION,
                            "request_id": request_id,
                            "device_id": target_device_id,
                            "assignment_id": assignment_id,
                            "match_id": match_id,
                            "commands": prepared_commands,
                        },
                    )
                    for index, command in enumerate(prepared_commands):
                        function = str(command.get("Function") or "")
                        results.append({
                            "index": index,
                            "function": function,
                            "ok": bool(delivered),
                            "reason": "" if delivered else "Agent сейчас offline",
                            "delivery": "agent-websocket-batch",
                            "ack_waited": False,
                        })
                        if delivered:
                            self._track_runtime_overlay_command(
                                target_device_id,
                                command,
                                sequence_id=sequence_id,
                                sequence_name=sequence_name,
                                button_id=button_id,
                            )
                else:
                    for index, command in enumerate(prepared_commands):
                        function = str(command.get("Function") or "")
                        request_id = secrets.token_urlsafe(12)
                        delivered = await self.send(
                            target_device_id,
                            {
                                "type": "vmix.command",
                                "protocol": AGENT_PROTOCOL_VERSION,
                                "request_id": request_id,
                                "device_id": target_device_id,
                                "assignment_id": assignment_id,
                                "match_id": match_id,
                                "command": command,
                            },
                        )
                        item = {
                            "index": index,
                            "function": function,
                            "ok": bool(delivered),
                            "reason": "" if delivered else "Agent сейчас offline",
                            "delivery": "agent-websocket",
                            "ack_waited": False,
                        }
                        results.append(item)
                        if delivered:
                            # With no ACK wait the local ON AIR state is intentionally optimistic:
                            # success means delivery to the live Agent WebSocket, not vMix confirmation.
                            self._track_runtime_overlay_command(
                                target_device_id,
                                command,
                                sequence_id=sequence_id,
                                sequence_name=sequence_name,
                                button_id=button_id,
                            )
                        else:
                            break
        else:
            async with self._device_vmix_send_lock(target_device_id):
                if use_batch:
                    ack = await self._send_vmix_batch_unlocked(
                        target_device_id,
                        assignment_id=assignment_id,
                        match_id=match_id,
                        commands=prepared_commands,
                        timeout=max(4.0, min(8.0, timeout + 2.0)),
                    )
                    ack_results = ack.get("results") if isinstance(ack.get("results"), list) else []
                    for index, command in enumerate(prepared_commands):
                        function = str(command.get("Function") or "")
                        item_ack = ack_results[index] if index < len(ack_results) and isinstance(ack_results[index], dict) else {}
                        ok = bool(item_ack.get("ok")) if item_ack else bool(ack.get("ok"))
                        item = {
                            "index": index,
                            "function": function,
                            "ok": ok,
                            "reason": str(item_ack.get("reason") or item_ack.get("error") or ("" if ok else ack.get("reason") or ack.get("error") or "vmix_batch_error")),
                        }
                        results.append(item)
                        if ok:
                            self._track_runtime_overlay_command(target_device_id, command)
                else:
                    for index, command in enumerate(prepared_commands):
                        function = str(command.get("Function") or "")
                        try:
                            ack = await self._send_vmix_command_unlocked(
                                target_device_id,
                                assignment_id=assignment_id,
                                match_id=match_id,
                                command=command,
                                timeout=timeout,
                            )
                            item = {
                                "index": index,
                                "function": function,
                                "ok": bool(ack.get("ok")),
                                "reason": str(ack.get("reason") or ack.get("error") or ""),
                            }
                        except Exception as error:
                            detail = getattr(error, "detail", None)
                            if isinstance(detail, dict):
                                reason = str(detail.get("message") or detail)
                            else:
                                reason = str(detail or error or "vmix_command_error")
                            item = {
                                "index": index,
                                "function": function,
                                "ok": False,
                                "reason": reason[:500],
                            }
                        results.append(item)
                        if item["ok"]:
                            self._track_runtime_overlay_command(target_device_id, command)
                        if not item["ok"]:
                            break

        failed = [item for item in results if not item["ok"]]
        return {
            "ok": not failed and len(results) == len(prepared_commands),
            "device_id": target_device_id,
            "match_id": match_id,
            "assignment_id": assignment_id,
            "session_token": session_token,
            "applied": sum(1 for item in results if item["ok"]),
            "attempted": len(results),
            "requested": len(prepared_commands),
            "failed": len(failed),
            "results": results,
            "transport": transport,
            # BUILD102 compatibility marker: "confirmation": "not_waited" if interactive_shortcut else "ack"
            "confirmation": "not_waited" if no_ack_delivery else "ack",
            "overlay_state": self._runtime_overlay_payload(target_device_id),
        }

    async def admin_test_mapping_value(
        self,
        device_id: str,
        *,
        input_ref: str,
        selected_name: str,
        value: str,
        field_type: str = "text",
    ) -> dict[str, Any]:
        """Send one explicit Mapping Editor preview value to a live Agent.

        This method is exposed only through the admin-protected mapping route.
        It deliberately uses the device's *current* assignment so a stale
        Mapping Editor cannot address a different match after the operator has
        switched games.
        """
        device_id = self.normalise_device_id(device_id)
        input_ref = str(input_ref or "").strip()
        selected_name = str(selected_name or "").strip()
        value = str(value or "")
        field_type = str(field_type or "text").strip().lower()
        if not input_ref:
            raise HTTPException(status_code=422, detail="Не определён vMix Input")
        if not selected_name:
            raise HTTPException(status_code=422, detail="Не определено поле vMix")
        if len(input_ref) > 300 or len(selected_name) > 300 or len(value) > 4000:
            raise HTTPException(status_code=422, detail="Слишком длинные параметры тестовой команды")

        with self.database.session() as session:
            device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if device is None:
                raise HTTPException(status_code=404, detail="Agent не найден")
            assignment_id = str(device.current_assignment_key or "")
            match_id = str(device.current_match_external_id or "")
            if not assignment_id or not match_id:
                raise HTTPException(status_code=409, detail="У этого Agent сейчас не назначен матч")
            if not device.vmix_connected:
                raise HTTPException(status_code=409, detail="vMix этого Agent сейчас не подключён")

        function = _mapping_value_function(field_type)
        ack = await self.send_vmix_command(
            device_id,
            assignment_id=assignment_id,
            match_id=match_id,
            command={
                "Function": function,
                "Input": input_ref,
                "SelectedName": selected_name,
                "Value": value,
            },
        )
        if not bool(ack.get("ok")):
            reason = str(ack.get("reason") or ack.get("error") or "vMix отклонил команду")
            raise HTTPException(status_code=502, detail=f"Команда дошла до agent, но не выполнена: {reason}")
        return {
            "ok": True,
            "device_id": device_id,
            "assignment_id": assignment_id,
            "match_id": match_id,
            "function": function,
            "input": input_ref,
            "selected_name": selected_name,
            "value": value,
            "agent_ack": ack,
        }

    async def admin_test_mapping_batch(self, device_id: str, commands: list[Any]) -> dict[str, Any]:
        """Apply many unsaved Mapping Editor values using batch transport when supported."""
        device_id = self.normalise_device_id(device_id)
        with self.database.session() as session:
            device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if device is None:
                raise HTTPException(status_code=404, detail="Agent не найден")
            assignment_id = str(device.current_assignment_key or "")
            match_id = str(device.current_match_external_id or "")
            agent_version = str(device.agent_version or "")
            if not assignment_id or not match_id:
                raise HTTPException(status_code=409, detail="У этого Agent сейчас не назначен матч")
            if not device.vmix_connected:
                raise HTTPException(status_code=409, detail="vMix этого Agent сейчас не подключён")

        result: dict[str, Any] = {
            "ok": True, "device_id": device_id, "assignment_id": assignment_id,
            "match_id": match_id, "total": len(commands), "applied": 0, "errors": [],
            "transport": "batch" if self._agent_supports_batch(agent_version) else "legacy",
        }
        prepared: list[dict[str, Any]] = []
        for index, item in enumerate(commands):
            input_ref = str(getattr(item, "input", "") or "").strip()
            selected_name = str(getattr(item, "selected_name", "") or "").strip()
            value = str(getattr(item, "value", "") or "")
            field_type = str(getattr(item, "field_type", "text") or "text").strip().lower()
            requested_function = str(getattr(item, "function", "") or "").strip()
            data_key = str(getattr(item, "data_key", "") or "")
            if not input_ref or not selected_name:
                result["errors"].append({"index": index, "key": data_key, "reason": "missing_vmix_target"})
                continue
            if len(input_ref) > 300 or len(selected_name) > 300 or len(value) > 4000:
                result["errors"].append({"index": index, "key": data_key, "reason": "parameters_too_long"})
                continue
            function = requested_function or _mapping_value_function(field_type)
            if function not in _MAPPING_TEST_FUNCTIONS:
                result["errors"].append({"index": index, "key": data_key, "reason": "unsupported_function"})
                continue
            prepared.append({
                "index": index, "key": data_key, "field": selected_name,
                "command": {"Function": function, "Input": input_ref, "SelectedName": selected_name, "Value": value},
            })

        if prepared:
            use_batch = self._agent_supports_batch(agent_version)
            result["transport"] = "sequential_no_ack"
            transport = await self._send_mapping_entries_resilient(
                device_id, assignment_id=assignment_id, match_id=match_id,
                entries=prepared, use_batch=use_batch,
            )
            result["batch_chunks_total"] = int(transport.get("chunks_total") or 0)
            result["batch_chunks_applied"] = int(transport.get("chunks_ok") or 0)
            result["batch_chunks_failed"] = int(transport.get("chunks_failed") or 0)
            result["batch_packets_sent"] = int(transport.get("packets_sent") or 0)
            result["batch_retries"] = int(transport.get("retries") or 0)
            result["fallback_commands"] = int(transport.get("fallback_commands") or 0)
            result["mapping_commands_sent"] = int(transport.get("commands_sent") or 0)
            result["mapping_ack_waited"] = bool(transport.get("ack_waited", False))
            result["assignment_refreshed"] = bool(transport.get("assignment_refreshed", False))
            result["input_groups"] = int(transport.get("input_groups") or 0)
            ack_results = transport.get("results") if isinstance(transport.get("results"), list) else []
            for pos, item in enumerate(prepared):
                item_ack = ack_results[pos] if pos < len(ack_results) and isinstance(ack_results[pos], dict) else {}
                if bool(item_ack.get("ok")):
                    result["applied"] += 1
                else:
                    result["errors"].append({
                        "index": item["index"], "key": item["key"], "field": item["field"],
                        "reason": str(item_ack.get("reason") or item_ack.get("error") or "mapping_transport_error"),
                    })
        result["ok"] = not result["errors"]
        return result

    async def list_for_user(self, user: HockeyUser) -> dict[str, Any]:
        now = _utcnow()
        visible_since = now - timedelta(minutes=5)
        with self.database.session() as session:
            rows = list(
                session.scalars(
                    select(VmixDevice)
                    .where(
                        (VmixDevice.wfl_user_id == user.id)
                        | (VmixDevice.last_seen_at >= visible_since)
                    )
                    .order_by(desc(VmixDevice.is_active_for_account), desc(VmixDevice.last_seen_at))
                )
            )
            devices = [self._device_payload(row, viewer_user_id=user.id) for row in rows]
        live_ids = set(self._live)
        for device in devices:
            device["online"] = device["device_id"] in live_ids
            if device["paired_to_me"]:
                device["pair_state"] = "mine"
            elif device["paired"]:
                device["pair_state"] = "busy"
            else:
                device["pair_state"] = "free"
        active_device_ids = [
            item["device_id"] for item in devices
            if item["paired_to_me"] and item["active_for_account"]
        ]
        return {
            "protocol": AGENT_PROTOCOL_VERSION,
            "devices": devices,
            # Kept for backward compatibility with older browser bundles.
            "active_device_id": active_device_ids[0] if active_device_ids else "",
            "active_device_ids": active_device_ids,
        }

    async def pair_device(self, device_id: str, user: HockeyUser, *, make_active: bool = True) -> dict[str, Any]:
        """Pair one Agent without disabling other Agents owned by the account."""
        device_id = self.normalise_device_id(device_id)
        if device_id not in self._live:
            raise HTTPException(status_code=409, detail="Agent сейчас не подключён к серверу")
        now = _utcnow()
        with self.database.session() as session:
            row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if row is None:
                raise HTTPException(status_code=404, detail="Device ID не найден")
            if row.wfl_user_id and row.wfl_user_id != user.id:
                raise HTTPException(status_code=409, detail="Это устройство уже прикреплено к другому аккаунту")
            row.wfl_user_id = user.id
            row.login_snapshot = user.login
            row.paired_at = row.paired_at or now
            if make_active:
                row.is_active_for_account = True
            session.flush()
            payload = self._device_payload(row, viewer_user_id=user.id)

        await self.send(
            device_id,
            {
                "type": "pairing.confirmed",
                "protocol": AGENT_PROTOCOL_VERSION,
                "device_id": device_id,
                "account": {"id": user.id, "login": user.login},
                "active_for_account": payload["active_for_account"],
            },
        )
        # Do not steal the browser's current match automatically. A match is
        # assigned only when this concrete device is selected for that panel.
        payload["assignment"] = None
        return payload

    async def activate_device(self, device_id: str, user: HockeyUser) -> dict[str, Any]:
        """Enable one Agent. Other enabled Agents remain enabled."""
        device_id = self.normalise_device_id(device_id)
        with self.database.session() as session:
            row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if row is None or row.wfl_user_id != user.id:
                raise HTTPException(status_code=404, detail="Устройство не прикреплено к вашему аккаунту")
            row.is_active_for_account = True
            session.flush()
            payload = self._device_payload(row, viewer_user_id=user.id)
        await self.send(device_id, {"type": "device.activated", "device_id": device_id})
        return payload

    async def deactivate_device(self, device_id: str, user: HockeyUser) -> dict[str, Any]:
        """Stop routing this account's data/commands to one Agent only."""
        device_id = self.normalise_device_id(device_id)
        now = _utcnow()
        with self.database.session() as session:
            row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if row is None or row.wfl_user_id != user.id:
                raise HTTPException(status_code=404, detail="Устройство не прикреплено к вашему аккаунту")
            for assignment in session.scalars(
                select(VmixAssignment).where(
                    and_(VmixAssignment.device_id == row.id, VmixAssignment.active.is_(True))
                )
            ):
                assignment.active = False
                assignment.ended_at = now
                assignment.last_activity_at = now
            row.is_active_for_account = False
            row.current_match_external_id = ""
            row.current_assignment_key = ""
            session.flush()
            payload = self._device_payload(row, viewer_user_id=user.id)
        await self.send(device_id, {"type": "device.deactivated", "device_id": device_id, "reason": "disabled_by_operator"})
        return payload

    async def unpair_device(self, device_id: str, user: HockeyUser) -> dict[str, Any]:
        device_id = self.normalise_device_id(device_id)
        now = _utcnow()
        with self.database.session() as session:
            row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if row is None or row.wfl_user_id != user.id:
                raise HTTPException(status_code=404, detail="Устройство не прикреплено к вашему аккаунту")
            for assignment in session.scalars(
                select(VmixAssignment).where(
                    and_(VmixAssignment.device_id == row.id, VmixAssignment.active.is_(True))
                )
            ):
                assignment.active = False
                assignment.ended_at = now
            row.wfl_user_id = None
            row.login_snapshot = ""
            row.paired_at = None
            row.is_active_for_account = False
            row.current_match_external_id = ""
            row.current_assignment_key = ""
        await self.send(device_id, {"type": "pairing.revoked", "device_id": device_id})
        return {"ok": True, "device_id": device_id}

    async def assign_current_match(self, user: HockeyUser, *, device_id: str = "") -> dict[str, Any] | None:
        """Assign the user's current operator-session match to one concrete Agent."""
        with self.database.session() as session:
            operator = session.scalar(
                select(OperatorSession)
                .where(
                    and_(
                        OperatorSession.wfl_user_id == user.id,
                        OperatorSession.status == "active",
                    )
                )
                .order_by(desc(OperatorSession.id))
            )
            if operator is None or not operator.game_external_id:
                return None
            game_id = str(operator.game_external_id)
            tournament_id = str(operator.tournament_external_id)
            session_token = str(operator.session_token or "")
            selected_device_id = str(device_id or operator.vmix_device_uuid or "").strip()
        return await self.assign_match(
            wfl_user_id=user.id,
            game_external_id=game_id,
            tournament_external_id=tournament_id,
            device_id=selected_device_id,
            operator_session_token=session_token,
        )

    async def assign_match(
        self,
        *,
        wfl_user_id: str,
        game_external_id: str,
        tournament_external_id: str = "",
        device_id: str = "",
        operator_session_token: str = "",
        wait_for_mapping: bool = True,
    ) -> dict[str, Any] | None:
        """Assign a match to exactly one Agent. Never broadcast to all active Agents."""
        game_external_id = str(game_external_id or "").strip()
        requested_device_id = str(device_id or "").strip()
        operator_session_token = str(operator_session_token or "").strip()
        if not game_external_id:
            return None
        now = _utcnow()
        with self.database.session() as session:
            device = None
            if requested_device_id:
                requested_device_id = self.normalise_device_id(requested_device_id)
                device = session.scalar(
                    select(VmixDevice).where(
                        and_(
                            VmixDevice.device_uuid == requested_device_id,
                            VmixDevice.wfl_user_id == str(wfl_user_id),
                            VmixDevice.is_active_for_account.is_(True),
                        )
                    )
                )

            if device is None:
                # BUILD111: a browser may keep a stale localStorage device id after
                # reconnect/update. Do not silently leave the new match unassigned.
                # Automatic fallback is allowed only when routing is unambiguous:
                # exactly one live active Agent, or exactly one active Agent total.
                candidates = list(session.scalars(
                    select(VmixDevice)
                    .where(
                        and_(
                            VmixDevice.wfl_user_id == str(wfl_user_id),
                            VmixDevice.is_active_for_account.is_(True),
                        )
                    )
                    .order_by(desc(VmixDevice.last_seen_at))
                ))
                live_candidates = [item for item in candidates if item.device_uuid in self._live]
                if len(live_candidates) == 1:
                    device = live_candidates[0]
                elif len(candidates) == 1:
                    device = candidates[0]

            if device is None:
                return None

            if operator_session_token:
                operator = session.scalar(
                    select(OperatorSession).where(
                        and_(
                            OperatorSession.session_token == operator_session_token,
                            OperatorSession.wfl_user_id == str(wfl_user_id),
                            OperatorSession.status == "active",
                        )
                    )
                )
                if operator is not None:
                    operator.vmix_device_uuid = device.device_uuid
                    operator.last_activity_at = now

            current = session.scalar(
                select(VmixAssignment)
                .where(
                    and_(
                        VmixAssignment.device_id == device.id,
                        VmixAssignment.active.is_(True),
                    )
                )
                .order_by(desc(VmixAssignment.id))
            )
            same_binding = bool(
                current is not None
                and current.game_external_id == game_external_id
                and (not operator_session_token or current.operator_session_token == operator_session_token)
            )
            if same_binding:
                current.last_activity_at = now
                assignment = current
            else:
                if current is not None:
                    current.active = False
                    current.ended_at = now
                    current.last_activity_at = now
                assignment = VmixAssignment(
                    assignment_key=secrets.token_urlsafe(18),
                    device_id=device.id,
                    wfl_user_id=str(wfl_user_id),
                    operator_session_token=operator_session_token,
                    tournament_external_id=str(tournament_external_id or ""),
                    game_external_id=game_external_id,
                    active=True,
                    created_at=now,
                    last_activity_at=now,
                )
                session.add(assignment)
                session.flush()
            device.current_match_external_id = game_external_id
            device.current_assignment_key = assignment.assignment_key
            device.last_seen_at = device.last_seen_at or now
            resolved_device_id = device.device_uuid
            payload = self._assignment_payload(assignment, device_id=resolved_device_id)

        delivered = await self.send(
            resolved_device_id,
            {
                "type": "match.assign",
                "protocol": AGENT_PROTOCOL_VERSION,
                **payload,
            },
        )
        payload["delivered"] = delivered
        if delivered:
            if wait_for_mapping:
                payload["mapping_apply"] = await self.apply_mapping_to_device(
                    resolved_device_id, reason="match_assigned"
                )
            else:
                payload["mapping_apply"] = {
                    "ok": True,
                    "deferred": True,
                    "reason": "match_assigned",
                    "device_id": resolved_device_id,
                }
        return payload

    async def select_device_for_session(
        self,
        device_id: str,
        user: HockeyUser,
        *,
        session_token: str = "",
        wait_for_mapping: bool = True,
    ) -> dict[str, Any]:
        """Bind this browser/operator session to one enabled Agent."""
        device_id = self.normalise_device_id(device_id)
        session_token = str(session_token or "").strip()
        with self.database.session() as session:
            device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if device is None or device.wfl_user_id != user.id:
                raise HTTPException(status_code=404, detail="Устройство не прикреплено к вашему аккаунту")
            if not device.is_active_for_account:
                raise HTTPException(status_code=409, detail="Сначала включите получение данных для этого Agent")
            operator = None
            if session_token:
                operator = session.scalar(
                    select(OperatorSession).where(
                        and_(
                            OperatorSession.session_token == session_token,
                            OperatorSession.wfl_user_id == user.id,
                            OperatorSession.status == "active",
                        )
                    )
                )
                if operator is None:
                    raise HTTPException(status_code=404, detail="Матчевая сессия не найдена")
                operator.vmix_device_uuid = device_id
                game_id = str(operator.game_external_id or "")
                tournament_id = str(operator.tournament_external_id or "")
            else:
                game_id = ""
                tournament_id = ""

        assignment = None
        if game_id:
            assignment = await self.assign_match(
                wfl_user_id=user.id,
                game_external_id=game_id,
                tournament_external_id=tournament_id,
                device_id=device_id,
                operator_session_token=session_token,
                wait_for_mapping=bool(wait_for_mapping),
            )
        return {
            "ok": True,
            "device_id": device_id,
            "session_token": session_token,
            "assignment": assignment,
        }

    async def reconcile_device(self, device_id: str) -> None:
        """Restore only this Agent's own assignment after reconnect."""
        with self.database.session() as session:
            device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if device is None or not device.wfl_user_id:
                return
            user_id = device.wfl_user_id
            login = device.login_snapshot
            active = device.is_active_for_account
            assignment = None
            if active and device.current_assignment_key:
                assignment = session.scalar(
                    select(VmixAssignment).where(
                        and_(
                            VmixAssignment.device_id == device.id,
                            VmixAssignment.assignment_key == device.current_assignment_key,
                            VmixAssignment.active.is_(True),
                        )
                    )
                )
            assignment_payload = self._assignment_payload(assignment, device_id=device_id) if assignment is not None else None
        await self.send(
            device_id,
            {
                "type": "pairing.confirmed",
                "protocol": AGENT_PROTOCOL_VERSION,
                "device_id": device_id,
                "account": {"id": user_id, "login": login},
                "active_for_account": active,
            },
        )
        if active and assignment_payload is not None:
            delivered = await self.send(
                device_id,
                {"type": "match.assign", "protocol": AGENT_PROTOCOL_VERSION, **assignment_payload},
            )
            if delivered:
                await self.apply_mapping_to_device(device_id, reason="agent_reconnected")

    async def receive_inventory(self, device_id: str, message: dict[str, Any]) -> None:
        inventory = message.get("inventory") if isinstance(message.get("inventory"), dict) else {}
        fingerprint = str(inventory.get("fingerprint") or "").strip().lower()
        inputs = inventory.get("inputs") if isinstance(inventory.get("inputs"), list) else []
        if not re.fullmatch(r"[0-9a-f]{64}", fingerprint) or len(inputs) > 1000:
            return
        clean_inputs: list[dict[str, Any]] = []
        field_count = 0
        for raw_input in inputs:
            if not isinstance(raw_input, dict):
                continue
            raw_fields = raw_input.get("fields") if isinstance(raw_input.get("fields"), list) else []
            fields: list[dict[str, str]] = []
            for raw_field in raw_fields[:500]:
                if not isinstance(raw_field, dict):
                    continue
                name = str(raw_field.get("name") or "")[:300]
                if not name:
                    continue
                fields.append({
                    "name": name,
                    "type": str(raw_field.get("type") or "text")[:32],
                    "index": str(raw_field.get("index") or "")[:32],
                })
            field_count += len(fields)
            clean_inputs.append({
                "key": str(raw_input.get("key") or "")[:128],
                "number": str(raw_input.get("number") or "")[:32],
                "title": str(raw_input.get("title") or "")[:300],
                "type": str(raw_input.get("type") or "")[:64],
                "fields": fields,
            })
        agent_fingerprint = fingerprint
        stable_fingerprint = _stable_vmix_inventory_fingerprint(clean_inputs)
        clean_inventory = {
            "fingerprint": stable_fingerprint,
            "agent_fingerprint": agent_fingerprint,
            "inputs": clean_inputs,
            "input_count": len(clean_inputs),
            "field_count": field_count,
            "vmix_version": str(inventory.get("vmix_version") or "")[:64],
        }
        now = _utcnow()
        profile_payload: dict[str, Any] | None = None
        with self.database.session() as session:
            device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if device is None:
                return
            # Server-side stable fingerprint deliberately ignores Input numbers
            # and ordering. Moving an Input in vMix must not detach its Mapping.
            device.project_fingerprint = stable_fingerprint
            device.project_inventory_json = json.dumps(clean_inventory, ensure_ascii=False, separators=(",", ":"))
            device.project_input_count = len(clean_inputs)
            device.project_field_count = field_count
            device.project_scanned_at = now

            profile = self._active_mapping_profile_for_device(session, device)
            if profile is not None:
                # Refresh the profile inventory only when this project is its exact
                # stable identity. A compatibility-recovered profile may be shared
                # by another active Agent whose project contains a harmless extra
                # Input, so do not make the primary identity bounce between devices.
                if str(profile.project_fingerprint or "") == stable_fingerprint:
                    profile.inventory_json = device.project_inventory_json
                    profile.updated_at = now
                profile_payload = self._mapping_profile_payload(session, profile, include_inventory=False, include_fields=True)
        if profile_payload is not None:
            await self.send(device_id, {"type": "mapping.assigned", **profile_payload})
        else:
            await self.send(device_id, {"type": "mapping.missing", "project_fingerprint": stable_fingerprint})

    def _mapping_profile_payload(
        self,
        session: Any,
        profile: VmixMappingProfile,
        *,
        include_inventory: bool = True,
        include_fields: bool = True,
    ) -> dict[str, Any]:
        payload: dict[str, Any] = {
            "id": profile.id,
            "name": profile.name,
            "description": profile.description,
            "project_fingerprint": profile.project_fingerprint,
            "version": profile.version,
            "active": profile.active,
            "created_by": profile.created_by,
            "updated_by": profile.updated_by,
            "created_at": profile.created_at.isoformat() if profile.created_at else "",
            "updated_at": profile.updated_at.isoformat() if profile.updated_at else "",
        }
        if include_inventory:
            try:
                payload["inventory"] = json.loads(profile.inventory_json or "{}")
            except Exception:
                payload["inventory"] = {}
        if include_fields:
            rows = list(session.scalars(
                select(VmixMappingField)
                .where(VmixMappingField.profile_id == profile.id)
                .order_by(VmixMappingField.sort_order, VmixMappingField.id)
            ))
            payload["fields"] = [
                {
                    "id": row.id,
                    "graphic": row.graphic,
                    "data_key": row.data_key,
                    "vmix_input_key": row.vmix_input_key,
                    "vmix_input_number": row.vmix_input_number,
                    "vmix_input_title": row.vmix_input_title,
                    "vmix_field": row.vmix_field,
                    "field_type": row.field_type,
                    "rule": _mapping_rule_payload(row.rule_json),
                    "enabled": row.enabled,
                    "sort_order": row.sort_order,
                }
                for row in rows
            ]
        return payload

    def _mapping_device_display_payload(self, session: Any, profile: VmixMappingProfile) -> dict[str, Any]:
        source_id = self._runtime_mapping_source_id(profile)
        if source_id:
            source = session.get(VmixMappingProfile, source_id)
            if source is not None:
                return {
                    "id": source.id,
                    "name": source.name,
                    "version": source.version,
                    "runtime_profile_id": profile.id,
                    "source_profile_id": source.id,
                }
        return {"id": profile.id, "name": profile.name, "version": profile.version, "source_profile_id": profile.id}

    async def list_mapping_devices(self) -> dict[str, Any]:
        with self.database.session() as session:
            rows = list(session.scalars(select(VmixDevice).order_by(desc(VmixDevice.last_seen_at))))
            items = []
            for row in rows:
                profile = self._active_mapping_profile_for_device(session, row)
                items.append({
                    "device_id": row.device_uuid,
                    "name": row.name or row.hostname or row.device_uuid,
                    "hostname": row.hostname,
                    "agent_version": row.agent_version or "",
                    "mapping_supported": self._agent_supports_mapping_inventory(row.agent_version),
                    "online": row.device_uuid in self._live,
                    "vmix_connected": bool(row.vmix_connected),
                    "vmix_version": row.vmix_version,
                    "owner": row.login_snapshot,
                    "project_fingerprint": row.project_fingerprint,
                    "input_count": row.project_input_count,
                    "field_count": row.project_field_count,
                    "scanned_at": row.project_scanned_at.isoformat() if row.project_scanned_at else "",
                    "mapping": (self._mapping_device_display_payload(session, profile) if profile else None),
                })
        return {"devices": items}

    @staticmethod
    def _prepared_inventory_from_row(device: VmixDevice | None) -> dict[str, Any]:
        if device is None:
            return {}
        try:
            parsed = json.loads(device.project_inventory_json or "{}")
            return parsed if isinstance(parsed, dict) else {}
        except Exception:
            return {}

    @staticmethod
    def _prepared_find_input(inventory: dict[str, Any], *, key: str = "", number: str = "", title: str = "") -> dict[str, Any] | None:
        inputs = inventory.get("inputs") if isinstance(inventory, dict) and isinstance(inventory.get("inputs"), list) else []
        key = str(key or "").strip()
        number = str(number or "").strip()
        title = str(title or "").strip()
        if key:
            found = next((item for item in inputs if isinstance(item, dict) and str(item.get("key") or "").strip() == key), None)
            if found is not None:
                return found
        if number:
            found = next((item for item in inputs if isinstance(item, dict) and str(item.get("number") or "").strip() == number), None)
            if found is not None:
                return found
        if title:
            matches = [item for item in inputs if isinstance(item, dict) and str(item.get("title") or "").strip() == title]
            if len(matches) == 1:
                return matches[0]
        return None

    def _prepared_device_for_user(self, session: Any, user: HockeyUser, requested_device_id: str = "") -> VmixDevice:
        requested = str(requested_device_id or "").strip()
        if requested:
            requested = self.normalise_device_id(requested)
            device = session.scalar(select(VmixDevice).where(and_(VmixDevice.device_uuid == requested, VmixDevice.wfl_user_id == user.id)))
            if device is None:
                raise HTTPException(status_code=404, detail="Agent не принадлежит текущему аккаунту")
            return device
        rows = list(session.scalars(
            select(VmixDevice)
            .where(and_(VmixDevice.wfl_user_id == user.id, VmixDevice.is_active_for_account.is_(True)))
            .order_by(desc(VmixDevice.last_seen_at))
        ))
        if not rows:
            raise HTTPException(status_code=409, detail="Hockey Agent не выбран")
        live = next((row for row in rows if row.device_uuid in self._live and bool(row.vmix_connected)), None)
        return live or rows[0]

    @staticmethod
    def _prepared_title_payload(row: VmixPreparedTitle, inventory: dict[str, Any] | None = None) -> dict[str, Any]:
        try:
            fields = json.loads(row.field_values_json or "{}")
            if not isinstance(fields, dict):
                fields = {}
        except Exception:
            fields = {}
        clone_key = str(row.clone_input_key or "")
        clone_number = str(row.clone_input_number or "")
        clone_title = str(row.clone_input_title or "")
        if inventory:
            match = VmixAgentHub._prepared_find_input(inventory, key=clone_key, number=clone_number, title=clone_title)
            if match is not None:
                clone_key = str(match.get("key") or clone_key)
                clone_number = str(match.get("number") or clone_number)
                clone_title = str(match.get("title") or clone_title)
        return {
            "id": row.id,
            "name": row.name,
            "game_id": row.game_external_id,
            "device_id": row.device_uuid,
            "source_kind": row.source_kind,
            "source_ref": row.source_ref,
            "source_input": {
                "key": row.source_input_key,
                "number": row.source_input_number,
                "title": row.source_input_title,
            },
            "clone_input": {"key": clone_key, "number": clone_number, "title": clone_title},
            "field_values": fields,
            "created_at": row.created_at.isoformat() if row.created_at else "",
            "updated_at": row.updated_at.isoformat() if row.updated_at else "",
        }

    @staticmethod
    def _prepared_saved_fields(row: VmixPreparedTitle) -> dict[str, dict[str, str]]:
        try:
            payload = json.loads(row.field_values_json or "{}")
        except Exception:
            payload = {}
        if not isinstance(payload, dict):
            return {}
        result: dict[str, dict[str, str]] = {}
        for raw_name, raw_entry in list(payload.items())[:500]:
            name = str(raw_name or "").strip()[:300]
            if not name:
                continue
            if isinstance(raw_entry, dict):
                value = "" if raw_entry.get("value") is None else str(raw_entry.get("value"))
                field_type = str(raw_entry.get("type") or "text").strip().lower()
            else:
                value = "" if raw_entry is None else str(raw_entry)
                field_type = "text"
            if field_type not in {"text", "image", "source", "color", "colour"}:
                field_type = "text"
            result[name] = {"value": value[:8000], "type": field_type}
        return result

    @staticmethod
    def _prepared_next_default_name(session: Any, *, user_id: str, device_uuid: str, game_id: str) -> str:
        query = select(VmixPreparedTitle.name).where(VmixPreparedTitle.wfl_user_id == user_id)
        if device_uuid:
            query = query.where(VmixPreparedTitle.device_uuid == device_uuid)
        if game_id:
            query = query.where(VmixPreparedTitle.game_external_id == game_id)
        used: set[int] = set()
        for raw_name in session.scalars(query):
            match = re.fullmatch(r"\s*Заготовка\s+(\d+)\s*", str(raw_name or ""), flags=re.IGNORECASE)
            if match:
                used.add(int(match.group(1)))
        number = 1
        while number in used:
            number += 1
        return f"Заготовка {number}"

    @staticmethod
    def _prepared_field_update_commands(
        *,
        input_ref: str,
        available_fields: list[dict[str, Any]],
        raw_values: Any,
        existing_fields: dict[str, dict[str, str]] | None = None,
    ) -> tuple[dict[str, dict[str, str]], list[dict[str, Any]]]:
        values = raw_values if isinstance(raw_values, dict) else {}
        saved_fields = dict(existing_fields or {})
        allowed: dict[str, dict[str, Any]] = {
            str(field.get("name") or ""): field
            for field in available_fields
            if isinstance(field, dict) and str(field.get("name") or "")
        }
        # An old Agent inventory may temporarily miss fields of the freshly
        # cloned Input. Existing saved fields are still safe edit targets.
        for name, entry in saved_fields.items():
            allowed.setdefault(name, {"name": name, "type": str(entry.get("type") or "text")})

        commands: list[dict[str, Any]] = []
        for raw_name, raw_entry in list(values.items())[:240]:
            name = str(raw_name or "").strip()[:300]
            field = allowed.get(name)
            if not name or field is None:
                continue
            if isinstance(raw_entry, dict):
                value = "" if raw_entry.get("value") is None else str(raw_entry.get("value"))
                requested_type = str(raw_entry.get("type") or "").strip().lower()
            else:
                value = "" if raw_entry is None else str(raw_entry)
                requested_type = ""
            existing_type = str(saved_fields.get(name, {}).get("type") or "").strip().lower()
            field_type = str(field.get("type") or requested_type or existing_type or "text").strip().lower()
            if field_type not in {"text", "image", "source", "color", "colour"}:
                field_type = requested_type if requested_type in {"text", "image", "source", "color", "colour"} else "text"
            saved_fields[name] = {"value": value[:8000], "type": field_type}
            # Empty text deliberately clears inherited text. Some vMix title
            # engines reject an empty image/source/color, so leave those alone.
            if not value and field_type in {"image", "source", "color", "colour"}:
                continue
            commands.append({
                "Function": _mapping_value_function(field_type),
                "Input": input_ref,
                "SelectedName": name,
                "Value": value,
            })
        return saved_fields, commands

    async def list_prepared_titles(self, user: HockeyUser, *, device_id: str = "", game_id: str = "") -> dict[str, Any]:
        with self.database.session() as session:
            device = self._prepared_device_for_user(session, user, device_id)
            resolved_game = str(game_id or device.current_match_external_id or "").strip()
            query = select(VmixPreparedTitle).where(VmixPreparedTitle.wfl_user_id == user.id)
            if resolved_game:
                query = query.where(VmixPreparedTitle.game_external_id == resolved_game)
            if device.device_uuid:
                query = query.where(VmixPreparedTitle.device_uuid == device.device_uuid)
            rows = list(session.scalars(query.order_by(desc(VmixPreparedTitle.created_at), desc(VmixPreparedTitle.id))))
            inventory = self._prepared_inventory_from_row(device)
            # Refresh clone key/title opportunistically after Agent inventory catches up.
            for row in rows:
                found = self._prepared_find_input(
                    inventory,
                    key=str(row.clone_input_key or ""),
                    number=str(row.clone_input_number or ""),
                    title=str(row.clone_input_title or ""),
                )
                if found is not None:
                    row.clone_input_key = str(found.get("key") or row.clone_input_key or "")[:128]
                    row.clone_input_number = str(found.get("number") or row.clone_input_number or "")[:32]
                    found_title = str(found.get("title") or "")[:300]
                    # Right after SetInputName the Agent inventory can lag by one
                    # scan. Do not overwrite a freshly stored desired title with
                    # the old inherited source name during that short window.
                    if found_title and (str(row.clone_input_title or "") != str(row.name or "") or found_title == str(row.name or "")):
                        row.clone_input_title = found_title
            items = [self._prepared_title_payload(row, inventory) for row in rows]
            return {
                "device_id": device.device_uuid,
                "game_id": resolved_game,
                "items": items,
            }

    def _prepared_mapping_context(self, session: Any, device: VmixDevice, user: HockeyUser) -> tuple[VmixMappingProfile | None, dict[str, Any], dict[str, Any]]:
        profile = self._active_mapping_profile_for_device(session, device)
        if profile is None:
            return None, {}, {}
        assignment = session.scalar(
            select(VmixAssignment)
            .where(and_(VmixAssignment.device_id == device.id, VmixAssignment.active.is_(True)))
            .order_by(desc(VmixAssignment.id))
        )
        context = {
            "game_id": str(device.current_match_external_id or ""),
            "device_id": device.device_uuid,
            "assignment_id": str(device.current_assignment_key or ""),
            "tournament_id": str(assignment.tournament_external_id or "") if assignment is not None else "",
        }
        preference = session.get(UserPreference, user.id)
        if preference is not None:
            language = str(preference.vmix_language or preference.display_language or "").strip().lower()
            if language in {"ru", "en"}:
                context["ui_language"] = language
        inventory = self._prepared_inventory_from_row(device)
        return profile, context, inventory

    async def prepared_mapping_sources(self, user: HockeyUser, *, device_id: str = "", panel_id: str = "") -> dict[str, Any]:
        panel_id = re.sub(r"[^A-Za-z0-9_]+", "_", str(panel_id or "").strip()).strip("_")[:64]
        with self.database.session() as session:
            device = self._prepared_device_for_user(session, user, device_id)
            profile, _context, inventory = self._prepared_mapping_context(session, device, user)
            if profile is None:
                return {"device_id": device.device_uuid, "profile_id": 0, "items": []}
            fields = list(session.scalars(
                select(VmixMappingField)
                .where(and_(VmixMappingField.profile_id == profile.id, VmixMappingField.enabled.is_(True)))
                .order_by(VmixMappingField.sort_order, VmixMappingField.id)
            ))
            if panel_id:
                prefix = f"player_select.{panel_id}."
                fields = [field for field in fields if str(field.data_key or "").startswith(prefix)]
            grouped: dict[str, dict[str, Any]] = {}
            for field in fields:
                identity = str(field.vmix_input_key or field.vmix_input_title or field.vmix_input_number or "").strip()
                if not identity:
                    continue
                item = grouped.setdefault(identity, {
                    "key": str(field.vmix_input_key or ""),
                    "number": str(field.vmix_input_number or ""),
                    "title": str(field.vmix_input_title or ""),
                    "mapped_fields": 0,
                })
                item["mapped_fields"] += 1
            for item in grouped.values():
                found = self._prepared_find_input(inventory, key=item["key"], number=item["number"], title=item["title"])
                if found is not None:
                    item.update({
                        "key": str(found.get("key") or item["key"]),
                        "number": str(found.get("number") or item["number"]),
                        "title": str(found.get("title") or item["title"]),
                        "type": str(found.get("type") or ""),
                    })
            items = sorted(grouped.values(), key=lambda value: (int(value.get("number") or 999999) if str(value.get("number") or "").isdigit() else 999999, str(value.get("title") or "").casefold()))
            return {"device_id": device.device_uuid, "profile_id": profile.id, "items": items}

    async def prepared_mapping_snapshot(
        self,
        user: HockeyUser,
        *,
        device_id: str = "",
        input_key: str = "",
        input_number: str = "",
        input_title: str = "",
        panel_id: str = "",
    ) -> dict[str, Any]:
        panel_id = re.sub(r"[^A-Za-z0-9_]+", "_", str(panel_id or "").strip()).strip("_")[:64]
        with self.database.session() as session:
            device = self._prepared_device_for_user(session, user, device_id)
            profile, context, inventory = self._prepared_mapping_context(session, device, user)
            if profile is None:
                return {"device_id": device.device_uuid, "profile_id": 0, "fields": []}
            source = self._prepared_find_input(inventory, key=input_key, number=input_number, title=input_title)
            source_key = str(source.get("key") or input_key or "") if source else str(input_key or "")
            source_number = str(source.get("number") or input_number or "") if source else str(input_number or "")
            source_title = str(source.get("title") or input_title or "") if source else str(input_title or "")
            fields = list(session.scalars(
                select(VmixMappingField)
                .where(and_(VmixMappingField.profile_id == profile.id, VmixMappingField.enabled.is_(True)))
                .order_by(VmixMappingField.sort_order, VmixMappingField.id)
            ))
            if panel_id:
                prefix = f"player_select.{panel_id}."
                fields = [field for field in fields if str(field.data_key or "").startswith(prefix)]
            def target_matches(field: VmixMappingField) -> bool:
                if source_key and str(field.vmix_input_key or "") == source_key:
                    return True
                if source_title and str(field.vmix_input_title or "") == source_title:
                    return True
                if source_number and str(field.vmix_input_number or "") == source_number:
                    return True
                return False
            fields = [field for field in fields if target_matches(field)]
            catalog = self.mapping_data.data_catalog(user, context)
            by_key = {str(item.get("key") or ""): item for item in (catalog.get("items") or []) if item.get("key")}
            result_fields = []
            for field in fields:
                item = by_key.get(str(field.data_key or ""))
                if item is None:
                    continue
                result_fields.append({
                    "name": str(field.vmix_field or ""),
                    "type": str(field.field_type or "text"),
                    "data_key": str(field.data_key or ""),
                    "value": "" if item.get("value") is None else str(item.get("value")),
                })
            return {
                "device_id": device.device_uuid,
                "game_id": str(device.current_match_external_id or ""),
                "profile_id": profile.id,
                "input": {"key": source_key, "number": source_number, "title": source_title},
                "fields": result_fields,
            }

    async def create_prepared_title(self, user: HockeyUser, payload: Any) -> dict[str, Any]:
        requested_device = str(getattr(payload, "device_id", "") or "").strip()
        session_token = str(getattr(payload, "session_token", "") or "").strip()
        with self.database.session() as session:
            device = self._prepared_device_for_user(session, user, requested_device)
            current_game_id = str(device.current_match_external_id or "").strip()
            inventory = self._prepared_inventory_from_row(device)
            source = self._prepared_find_input(
                inventory,
                key=str(getattr(payload, "source_input_key", "") or ""),
                number=str(getattr(payload, "source_input_number", "") or ""),
                title=str(getattr(payload, "source_input_title", "") or ""),
            )
            if source is None:
                raise HTTPException(status_code=404, detail="Исходный vMix Input не найден в текущем проекте")
            source_key = str(source.get("key") or "")
            source_number = str(source.get("number") or "")
            source_title = str(source.get("title") or "")
            source_ref = source_key or source_number or source_title
            before_inputs = [item for item in (inventory.get("inputs") or []) if isinstance(item, dict)]
            before_keys = {str(item.get("key") or "") for item in before_inputs if str(item.get("key") or "")}
            before_numbers = {str(item.get("number") or "") for item in before_inputs if str(item.get("number") or "")}
            numeric_numbers = [int(value) for value in before_numbers if value.isdigit()]
            expected_number = str((max(numeric_numbers) if numeric_numbers else len(before_inputs)) + 1)

        create_result = await self.run_vmix_sequence_for_user(
            user,
            [{"Function": "CreateVirtualInput", "Input": source_ref}],
            device_id=device.device_uuid,
            session_token=session_token,
            timeout=6.0,
        )
        target_device_id = str(create_result.get("device_id") or device.device_uuid)
        match_id = str(create_result.get("match_id") or current_game_id or "").strip()

        requested_name = str(getattr(payload, "name", "") or "").strip()[:200]
        if requested_name:
            title_name = requested_name
        else:
            with self.database.session() as session:
                title_name = self._prepared_next_default_name(
                    session,
                    user_id=user.id,
                    device_uuid=target_device_id,
                    game_id=match_id,
                )

        clone: dict[str, Any] | None = None
        current_inputs: list[dict[str, Any]] = []
        for _ in range(12):
            await asyncio.sleep(0.18)
            with self.database.session() as session:
                refreshed = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == target_device_id))
                current_inventory = self._prepared_inventory_from_row(refreshed)
            current_inputs = [item for item in (current_inventory.get("inputs") or []) if isinstance(item, dict)]
            candidates = [item for item in current_inputs if (str(item.get("key") or "") and str(item.get("key") or "") not in before_keys) or (str(item.get("number") or "") and str(item.get("number") or "") not in before_numbers)]
            if candidates:
                clone = sorted(candidates, key=lambda item: int(item.get("number") or 0) if str(item.get("number") or "").isdigit() else 0)[-1]
                break
        clone_key = str(clone.get("key") or "") if clone else ""
        clone_number = str(clone.get("number") or expected_number) if clone else expected_number
        clone_ref = clone_key or clone_number or str(clone.get("title") or source_title if clone else source_title)

        raw_values = getattr(payload, "field_values", {})
        source_fields = source.get("fields") if isinstance(source.get("fields"), list) else []
        saved_fields, field_commands = self._prepared_field_update_commands(
            input_ref=clone_ref,
            available_fields=source_fields,
            raw_values=raw_values,
        )

        # BUILD99: the prepared title name is also the actual vMix Input display
        # name. vMix exposes SetInputName through the Shortcut/API surface.
        commands: list[dict[str, Any]] = [
            {"Function": "SetInputName", "Input": clone_ref, "Value": title_name},
            *field_commands,
        ]

        # vMix supports selecting categories via API, but does not expose a
        # supported shortcut to create/label a custom category or assign an
        # Input to it. Keep the safe fallback requested by the operator: the
        # prepared title stays at the end of the project. CreateVirtualInput
        # normally appends already; MoveInput makes that deterministic if the
        # project reordered while the clone was being discovered.
        current_numbers = [int(str(item.get("number") or "")) for item in current_inputs if str(item.get("number") or "").isdigit()]
        end_position = max(current_numbers) if current_numbers else 0
        if end_position > 0 and clone_number.isdigit() and int(clone_number) != end_position:
            commands.append({"Function": "MoveInput", "Input": clone_ref, "Value": str(end_position)})
            clone_number = str(end_position)

        set_result = await self.run_vmix_sequence_for_user(
            user,
            commands,
            device_id=target_device_id,
            session_token=session_token,
            timeout=max(4.0, min(12.0, 3.0 + len(commands) * 0.04)),
        )

        source_kind = re.sub(r"[^A-Za-z0-9_-]+", "_", str(getattr(payload, "source_kind", "manual") or "manual").strip())[:32] or "manual"
        source_ref_name = re.sub(r"[^A-Za-z0-9_.:-]+", "_", str(getattr(payload, "source_ref", "") or "").strip())[:128]
        with self.database.session() as session:
            row = VmixPreparedTitle(
                wfl_user_id=user.id,
                game_external_id=match_id,
                device_uuid=target_device_id,
                name=title_name,
                source_kind=source_kind,
                source_ref=source_ref_name,
                source_input_key=source_key,
                source_input_number=source_number,
                source_input_title=source_title,
                clone_input_key=clone_key,
                clone_input_number=clone_number,
                clone_input_title=title_name,
                field_values_json=json.dumps(saved_fields, ensure_ascii=False, separators=(",", ":")),
                created_by=user.id,
                created_at=_utcnow(),
                updated_at=_utcnow(),
            )
            session.add(row)
            session.flush()
            result = self._prepared_title_payload(row)
        result["ok"] = True
        result["create_result"] = create_result
        result["set_result"] = set_result
        result["placement"] = {
            "requested_category": "Заготовки",
            "mode": "end",
            "category_supported": False,
            "reason": "vmix_shortcut_api_has_no_input_category_assignment",
        }
        return result

    async def update_prepared_title(self, prepared_id: int, user: HockeyUser, payload: Any) -> dict[str, Any]:
        session_token = str(getattr(payload, "session_token", "") or "").strip()
        with self.database.session() as session:
            row = session.get(VmixPreparedTitle, int(prepared_id))
            if row is None or row.wfl_user_id != user.id:
                raise HTTPException(status_code=404, detail="Заготовка не найдена")
            requested_device = str(getattr(payload, "device_id", "") or row.device_uuid or "").strip()
            device = self._prepared_device_for_user(session, user, requested_device)
            inventory = self._prepared_inventory_from_row(device)
            clone = self._prepared_find_input(
                inventory,
                key=str(row.clone_input_key or ""),
                number=str(row.clone_input_number or ""),
                title=str(row.clone_input_title or row.name or ""),
            )
            source = self._prepared_find_input(
                inventory,
                key=str(row.source_input_key or ""),
                number=str(row.source_input_number or ""),
                title=str(row.source_input_title or ""),
            )
            clone_ref = str(clone.get("key") or clone.get("number") or "") if clone is not None else str(row.clone_input_key or row.clone_input_number or row.clone_input_title or "")
            if not clone_ref:
                raise HTTPException(status_code=409, detail="Клонированный Input больше не найден в vMix")
            old_fields = self._prepared_saved_fields(row)
            available_fields = clone.get("fields") if clone is not None and isinstance(clone.get("fields"), list) else []
            if not available_fields and source is not None and isinstance(source.get("fields"), list):
                available_fields = source.get("fields")
            old_name = str(row.name or "").strip()
            requested_name = str(getattr(payload, "name", "") or "").strip()[:200]
            title_name = requested_name or old_name
            if not title_name:
                title_name = self._prepared_next_default_name(
                    session,
                    user_id=user.id,
                    device_uuid=str(row.device_uuid or device.device_uuid or ""),
                    game_id=str(row.game_external_id or device.current_match_external_id or ""),
                )
            saved_fields, field_commands = self._prepared_field_update_commands(
                input_ref=clone_ref,
                available_fields=available_fields,
                raw_values=getattr(payload, "field_values", {}),
                existing_fields=old_fields,
            )
            target_device_id = device.device_uuid

        commands: list[dict[str, Any]] = [
            {"Function": "SetInputName", "Input": clone_ref, "Value": title_name},
            *field_commands,
        ]
        set_result = await self.run_vmix_sequence_for_user(
            user,
            commands,
            device_id=target_device_id,
            session_token=session_token,
            timeout=max(4.0, min(12.0, 3.0 + len(commands) * 0.04)),
        )

        with self.database.session() as session:
            row = session.get(VmixPreparedTitle, int(prepared_id))
            if row is None or row.wfl_user_id != user.id:
                raise HTTPException(status_code=404, detail="Заготовка не найдена")
            row.name = title_name
            row.clone_input_title = title_name[:300]
            if clone is not None:
                row.clone_input_key = str(clone.get("key") or row.clone_input_key or "")[:128]
                row.clone_input_number = str(clone.get("number") or row.clone_input_number or "")[:32]
            row.field_values_json = json.dumps(saved_fields, ensure_ascii=False, separators=(",", ":"))
            row.updated_at = _utcnow()
            result = self._prepared_title_payload(row)
        result["ok"] = True
        result["set_result"] = set_result
        result["updated_existing_input"] = True
        return result

    async def preview_prepared_title(self, prepared_id: int, user: HockeyUser, payload: Any) -> dict[str, Any]:
        with self.database.session() as session:
            row = session.get(VmixPreparedTitle, int(prepared_id))
            if row is None or row.wfl_user_id != user.id:
                raise HTTPException(status_code=404, detail="Заготовка не найдена")
            requested_device = str(getattr(payload, "device_id", "") or row.device_uuid or "").strip()
            device = self._prepared_device_for_user(session, user, requested_device)
            inventory = self._prepared_inventory_from_row(device)
            found = self._prepared_find_input(
                inventory,
                key=str(row.clone_input_key or ""),
                number=str(row.clone_input_number or ""),
                title=str(row.clone_input_title or ""),
            )
            input_ref = str(found.get("key") or found.get("number") or "") if found is not None else str(row.clone_input_key or row.clone_input_number or row.clone_input_title or "")
            if not input_ref:
                raise HTTPException(status_code=409, detail="Клонированный Input больше не найден в vMix")
        return await self.run_vmix_sequence_for_user(
            user,
            [{"Function": "PreviewInput", "Input": input_ref}],
            device_id=device.device_uuid,
            session_token=str(getattr(payload, "session_token", "") or ""),
            timeout=4.0,
        )

    async def delete_prepared_title(self, prepared_id: int, user: HockeyUser) -> dict[str, Any]:
        with self.database.session() as session:
            row = session.get(VmixPreparedTitle, int(prepared_id))
            if row is None or row.wfl_user_id != user.id:
                raise HTTPException(status_code=404, detail="Заготовка не найдена")
            # Deliberately do not RemoveInput in vMix: deleting a web list entry must
            # never unexpectedly destroy a studio graphic that may already be used.
            session.delete(row)
        return {"ok": True, "id": int(prepared_id), "vmix_input_removed": False}

    async def latest_vmix_inventory(self) -> dict[str, Any]:
        """Return the most recent connected vMix project inventory for editor helpers.

        Inventory is reported by Hockey Agent and stored on the device row, so the
        UI Builder can populate Input/SelectedName controls without talking to the
        operator's local vMix HTTP API directly.
        """
        with self.database.session() as session:
            rows = list(session.scalars(select(VmixDevice).order_by(desc(VmixDevice.last_seen_at))))
            devices: list[dict[str, Any]] = []
            selected: VmixDevice | None = None
            for row in rows:
                has_inventory = bool(str(row.project_inventory_json or "").strip())
                online = row.device_uuid in self._live
                devices.append({
                    "device_id": row.device_uuid,
                    "name": row.name or row.hostname or row.device_uuid,
                    "hostname": row.hostname or "",
                    "online": online,
                    "vmix_connected": bool(row.vmix_connected),
                    "vmix_version": row.vmix_version or "",
                    "input_count": int(row.project_input_count or 0),
                    "field_count": int(row.project_field_count or 0),
                    "scanned_at": row.project_scanned_at.isoformat() if row.project_scanned_at else "",
                    "has_inventory": has_inventory,
                })
                if selected is None and has_inventory and online and bool(row.vmix_connected):
                    selected = row
            if selected is None:
                selected = next((row for row in rows if str(row.project_inventory_json or "").strip() and bool(row.vmix_connected)), None)
            if selected is None:
                selected = next((row for row in rows if str(row.project_inventory_json or "").strip()), None)

            inventory: dict[str, Any] = {}
            if selected is not None:
                try:
                    parsed = json.loads(selected.project_inventory_json or "{}")
                    inventory = parsed if isinstance(parsed, dict) else {}
                except Exception:
                    inventory = {}
            return {
                "device_id": selected.device_uuid if selected is not None else "",
                "device_name": (selected.name or selected.hostname or selected.device_uuid) if selected is not None else "",
                "online": bool(selected is not None and selected.device_uuid in self._live),
                "vmix_connected": bool(selected.vmix_connected) if selected is not None else False,
                "inventory": inventory,
                "devices": devices,
            }

    async def vmix_inventory_for_user(self, user: HockeyUser, *, device_id: str = "") -> dict[str, Any]:
        """Return vMix inventory for one device owned by the current operator.

        This endpoint is intentionally read-only and available to regular hockey
        operators so settings pages can store stable Input keys without requiring
        the UI Builder editor PIN.
        """
        requested = self.normalise_device_id(device_id) if str(device_id or "").strip() else ""
        with self.database.session() as session:
            query = select(VmixDevice).where(VmixDevice.wfl_user_id == user.id)
            rows = list(session.scalars(query.order_by(desc(VmixDevice.last_seen_at))))
            selected: VmixDevice | None = None
            if requested:
                selected = next((row for row in rows if row.device_uuid == requested), None)
                if selected is None:
                    raise HTTPException(status_code=404, detail="Agent не принадлежит текущему аккаунту")
            else:
                selected = next((row for row in rows if row.device_uuid in self._live and bool(row.vmix_connected) and str(row.project_inventory_json or "").strip()), None)
                if selected is None:
                    selected = next((row for row in rows if str(row.project_inventory_json or "").strip()), None)

            inventory: dict[str, Any] = {}
            if selected is not None:
                try:
                    parsed = json.loads(selected.project_inventory_json or "{}")
                    inventory = parsed if isinstance(parsed, dict) else {}
                except Exception:
                    inventory = {}
            return {
                "device_id": selected.device_uuid if selected is not None else "",
                "device_name": (selected.name or selected.hostname or selected.device_uuid) if selected is not None else "",
                "online": bool(selected is not None and selected.device_uuid in self._live),
                "vmix_connected": bool(selected.vmix_connected) if selected is not None else False,
                "inventory": inventory,
            }


    @staticmethod
    def _portable_text(value: Any) -> str:
        return " ".join(str(value or "").strip().casefold().split())

    @classmethod
    def _rebind_portable_mapping_fields(
        cls,
        fields: list[dict[str, Any]],
        inventory: dict[str, Any],
    ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
        """Rebind saved Mapping targets to another vMix project inventory.

        Resolution order is deliberately conservative: stable Input key first,
        then a unique title, then a unique positional number as a legacy fallback.
        The selected vMix field itself must also exist on the resolved Input.
        """
        inputs = inventory.get("inputs") if isinstance(inventory, dict) and isinstance(inventory.get("inputs"), list) else []
        by_key: dict[str, dict[str, Any]] = {}
        by_title: dict[str, list[dict[str, Any]]] = {}
        by_number: dict[str, list[dict[str, Any]]] = {}
        for item in inputs:
            if not isinstance(item, dict):
                continue
            key = str(item.get("key") or "").strip()
            title = cls._portable_text(item.get("title"))
            number = str(item.get("number") or "").strip()
            if key:
                by_key[key] = item
            if title:
                by_title.setdefault(title, []).append(item)
            if number:
                by_number.setdefault(number, []).append(item)

        rebound: list[dict[str, Any]] = []
        skipped: list[dict[str, Any]] = []
        matched_by = {"key": 0, "title": 0, "number": 0}
        seen: set[tuple[str, str, str]] = set()

        for index, raw in enumerate((fields or [])[:2000]):
            if not isinstance(raw, dict):
                skipped.append({"index": index, "reason": "invalid_field"})
                continue
            data_key = str(raw.get("data_key") or "").strip()
            vmix_field = str(raw.get("vmix_field") or "").strip()
            if not data_key or not vmix_field:
                skipped.append({"index": index, "data_key": data_key, "vmix_field": vmix_field, "reason": "missing_mapping_key"})
                continue

            source_key = str(raw.get("vmix_input_key") or "").strip()
            source_title = str(raw.get("vmix_input_title") or "").strip()
            source_number = str(raw.get("vmix_input_number") or "").strip()
            target = by_key.get(source_key) if source_key else None
            resolution = "key" if target is not None else ""
            if target is None and source_title:
                title_matches = by_title.get(cls._portable_text(source_title), [])
                if len(title_matches) == 1:
                    target = title_matches[0]
                    resolution = "title"
                elif len(title_matches) > 1:
                    skipped.append({"index": index, "data_key": data_key, "input": source_title, "vmix_field": vmix_field, "reason": "ambiguous_input_title"})
                    continue
            if target is None and source_number:
                number_matches = by_number.get(source_number, [])
                if len(number_matches) == 1:
                    target = number_matches[0]
                    resolution = "number"
                elif len(number_matches) > 1:
                    skipped.append({"index": index, "data_key": data_key, "input": source_number, "vmix_field": vmix_field, "reason": "ambiguous_input_number"})
                    continue
            if target is None:
                skipped.append({"index": index, "data_key": data_key, "input": source_title or source_key or source_number, "vmix_field": vmix_field, "reason": "input_not_found"})
                continue

            target_fields = target.get("fields") if isinstance(target.get("fields"), list) else []
            exact = next((f for f in target_fields if isinstance(f, dict) and str(f.get("name") or "").strip() == vmix_field), None)
            selected_name = vmix_field
            if exact is None:
                folded = cls._portable_text(vmix_field)
                folded_matches = [f for f in target_fields if isinstance(f, dict) and cls._portable_text(f.get("name")) == folded]
                if len(folded_matches) == 1:
                    selected_name = str(folded_matches[0].get("name") or "").strip()
                else:
                    skipped.append({
                        "index": index, "data_key": data_key,
                        "input": str(target.get("title") or target.get("key") or target.get("number") or ""),
                        "vmix_field": vmix_field,
                        "reason": "field_not_found" if not folded_matches else "ambiguous_field",
                    })
                    continue

            target_key = str(target.get("key") or "").strip()
            target_title = str(target.get("title") or "").strip()
            target_number = str(target.get("number") or "").strip()
            dedupe = (data_key, target_key or target_title or target_number, selected_name)
            if dedupe in seen:
                skipped.append({"index": index, "data_key": data_key, "input": target_title, "vmix_field": selected_name, "reason": "duplicate_target"})
                continue
            seen.add(dedupe)
            matched_by[resolution or "title"] = matched_by.get(resolution or "title", 0) + 1
            rebound.append({
                "graphic": str(raw.get("graphic") or "")[:100],
                "data_key": data_key[:200],
                "vmix_input_key": target_key[:128],
                "vmix_input_number": target_number[:32],
                "vmix_input_title": target_title[:300],
                "vmix_field": selected_name[:300],
                "field_type": str(raw.get("field_type") or "text")[:32],
                "rule": dict(raw.get("rule") or {}) if isinstance(raw.get("rule"), dict) else {},
                "enabled": bool(raw.get("enabled", True)),
                "sort_order": len(rebound),
            })

        return rebound, {
            "total": min(len(fields or []), 2000),
            "mapped": len(rebound),
            "skipped": len(skipped),
            "matched_by": matched_by,
            "skipped_items": skipped[:200],
        }

    @staticmethod
    def _portable_profile_name(session: Any, desired: str, fallback: str) -> str:
        base = str(desired or fallback or "Imported Mapping").strip()[:200] or "Imported Mapping"
        if session.scalar(select(VmixMappingProfile).where(VmixMappingProfile.name == base)) is None:
            return base
        stem = base[:185]
        for number in range(2, 1000):
            candidate = f"{stem} ({number})"[:200]
            if session.scalar(select(VmixMappingProfile).where(VmixMappingProfile.name == candidate)) is None:
                return candidate
        return f"{stem} {secrets.token_hex(3)}"[:200]

    def _create_portable_profile_for_device(
        self,
        session: Any,
        *,
        device: VmixDevice,
        fields: list[dict[str, Any]],
        name: str,
        description: str,
        user: HockeyUser,
        replace_existing: bool,
    ) -> tuple[VmixMappingProfile, dict[str, Any]]:
        try:
            inventory = json.loads(device.project_inventory_json or "{}")
        except Exception:
            inventory = {}
        rebound, report = self._rebind_portable_mapping_fields(fields, inventory)
        if not rebound:
            raise HTTPException(status_code=409, detail="Не удалось сопоставить ни одной связи Mapping с выбранным vMix")

        conflict = session.scalar(
            select(VmixMappingProfile)
            .where(and_(VmixMappingProfile.project_fingerprint == device.project_fingerprint, VmixMappingProfile.active.is_(True)))
            .order_by(desc(VmixMappingProfile.updated_at), desc(VmixMappingProfile.id))
        )
        if conflict is not None:
            if not replace_existing:
                raise HTTPException(status_code=409, detail=f"Для этого vMix уже активен Mapping «{conflict.name}». Разрешите замену при переносе.")
            conflict.active = False
            conflict.updated_by = user.login
            conflict.updated_at = _utcnow()

        now = _utcnow()
        profile = VmixMappingProfile(
            name=self._portable_profile_name(session, name, "Imported Mapping"),
            description=str(description or "")[:4000],
            project_fingerprint=str(device.project_fingerprint or "")[:64],
            inventory_json=device.project_inventory_json or "{}",
            version=1,
            active=True,
            created_by=user.login,
            updated_by=user.login,
            created_at=now,
            updated_at=now,
        )
        session.add(profile)
        session.flush()
        for index, item in enumerate(rebound):
            session.add(VmixMappingField(
                profile_id=profile.id,
                graphic=str(item.get("graphic") or "")[:100],
                data_key=str(item.get("data_key") or "")[:200],
                vmix_input_key=str(item.get("vmix_input_key") or "")[:128],
                vmix_input_number=str(item.get("vmix_input_number") or "")[:32],
                vmix_input_title=str(item.get("vmix_input_title") or "")[:300],
                vmix_field=str(item.get("vmix_field") or "")[:300],
                field_type=str(item.get("field_type") or "text")[:32],
                rule_json=json.dumps(dict(item.get("rule") or {}), ensure_ascii=False, separators=(",", ":"))[:12000],
                enabled=bool(item.get("enabled", True)),
                sort_order=index,
            ))
        session.flush()
        return profile, report

    @staticmethod
    def _runtime_mapping_profile_name(source_profile_id: int, target_fingerprint: str) -> str:
        """Deterministic hidden profile used only to adapt one saved config to another vMix."""
        safe_fp = re.sub(r"[^a-zA-Z0-9]+", "", str(target_fingerprint or ""))[:32] or "project"
        return f"__AUTO_MAPPING__{int(source_profile_id)}__{safe_fp}"[:200]

    @staticmethod
    def _runtime_mapping_source_id(profile: VmixMappingProfile | None) -> int:
        if profile is None:
            return 0
        match = re.match(r"^__AUTO_MAPPING__(\d+)__", str(profile.name or ""))
        return int(match.group(1)) if match else 0

    def _upsert_runtime_mapping_for_device(
        self,
        session: Any,
        *,
        source: VmixMappingProfile,
        source_fields: list[dict[str, Any]],
        device: VmixDevice,
        user: HockeyUser,
    ) -> tuple[VmixMappingProfile, dict[str, Any]]:
        """Create/update a hidden rebinding of a saved config for one concrete vMix project."""
        try:
            inventory = json.loads(device.project_inventory_json or "{}")
        except Exception:
            inventory = {}
        rebound, report = self._rebind_portable_mapping_fields(source_fields, inventory)
        if not rebound:
            raise HTTPException(status_code=409, detail="Не удалось сопоставить ни одной связи выбранного Mapping с вашим vMix")

        target_fingerprint = str(device.project_fingerprint or "")[:64]
        runtime_name = self._runtime_mapping_profile_name(source.id, target_fingerprint)
        runtime = session.scalar(select(VmixMappingProfile).where(VmixMappingProfile.name == runtime_name))
        now = _utcnow()

        # Only one concrete mapping can drive a vMix project at a time.
        for other in session.scalars(
            select(VmixMappingProfile).where(and_(
                VmixMappingProfile.project_fingerprint == target_fingerprint,
                VmixMappingProfile.active.is_(True),
            ))
        ):
            if runtime is not None and other.id == runtime.id:
                continue
            other.active = False
            other.updated_by = user.login
            other.updated_at = now

        if runtime is None:
            runtime = VmixMappingProfile(
                name=runtime_name,
                description=f"Техническая адаптация Mapping #{source.id}: {source.name}"[:4000],
                project_fingerprint=target_fingerprint,
                inventory_json=device.project_inventory_json or "{}",
                version=1,
                active=True,
                created_by=user.login,
                updated_by=user.login,
                created_at=now,
                updated_at=now,
            )
            session.add(runtime)
            session.flush()
        else:
            runtime.description = f"Техническая адаптация Mapping #{source.id}: {source.name}"[:4000]
            runtime.project_fingerprint = target_fingerprint
            runtime.inventory_json = device.project_inventory_json or "{}"
            runtime.version = int(runtime.version or 0) + 1
            runtime.active = True
            runtime.updated_by = user.login
            runtime.updated_at = now
            session.execute(delete(VmixMappingField).where(VmixMappingField.profile_id == runtime.id))
            session.flush()

        for index, item in enumerate(rebound):
            session.add(VmixMappingField(
                profile_id=runtime.id,
                graphic=str(item.get("graphic") or "")[:100],
                data_key=str(item.get("data_key") or "")[:200],
                vmix_input_key=str(item.get("vmix_input_key") or "")[:128],
                vmix_input_number=str(item.get("vmix_input_number") or "")[:32],
                vmix_input_title=str(item.get("vmix_input_title") or "")[:300],
                vmix_field=str(item.get("vmix_field") or "")[:300],
                field_type=str(item.get("field_type") or "text")[:32],
                rule_json=json.dumps(dict(item.get("rule") or {}), ensure_ascii=False, separators=(",", ":"))[:12000],
                enabled=bool(item.get("enabled", True)),
                sort_order=index,
            ))
        session.flush()
        return runtime, report

    async def use_mapping_profile_for_user(self, profile_id: int, payload: Any, user: HockeyUser) -> dict[str, Any]:
        """Use any saved Mapping config on the Agent selected for the user's current match."""
        requested = str(getattr(payload, "device_id", "") or "").strip()
        session_token = str(getattr(payload, "session_token", "") or "").strip()

        with self.database.session() as session:
            source = session.get(VmixMappingProfile, profile_id)
            if source is None or self._runtime_mapping_source_id(source):
                raise HTTPException(status_code=404, detail="Mapping-конфиг не найден")

            operator = None
            if session_token:
                operator = session.scalar(select(OperatorSession).where(and_(
                    OperatorSession.session_token == session_token,
                    OperatorSession.wfl_user_id == user.id,
                    OperatorSession.status == "active",
                )))
            if operator is None:
                operator = session.scalar(
                    select(OperatorSession)
                    .where(and_(OperatorSession.wfl_user_id == user.id, OperatorSession.status == "active"))
                    .order_by(desc(OperatorSession.id))
                )
            if not requested and operator is not None:
                requested = str(operator.vmix_device_uuid or "").strip()

            if requested:
                requested = self.normalise_device_id(requested)
                device = session.scalar(select(VmixDevice).where(and_(
                    VmixDevice.device_uuid == requested,
                    VmixDevice.wfl_user_id == user.id,
                    VmixDevice.is_active_for_account.is_(True),
                )))
            else:
                candidates = list(session.scalars(
                    select(VmixDevice)
                    .where(and_(VmixDevice.wfl_user_id == user.id, VmixDevice.is_active_for_account.is_(True)))
                    .order_by(desc(VmixDevice.last_seen_at))
                ))
                device = candidates[0] if len(candidates) == 1 else None
                if device is not None:
                    requested = str(device.device_uuid)

            if device is None:
                raise HTTPException(status_code=409, detail="Не удалось определить ваш Agent. Выберите Agent для текущей сессии матча.")
            if not device.project_fingerprint or not str(device.project_inventory_json or "").strip():
                raise HTTPException(status_code=409, detail="Ваш Agent ещё не передал структуру vMix")
            if not bool(device.vmix_connected):
                raise HTTPException(status_code=409, detail="На вашем Agent сейчас не подключён vMix")

        # Make sure this concrete Agent is attached to the current match before sending values.
        assignment = await self.assign_current_match(user, device_id=requested)
        if assignment is None:
            raise HTTPException(status_code=409, detail="Сначала откройте матч в основном интерфейсе")

        with self.database.session() as session:
            source = session.get(VmixMappingProfile, profile_id)
            device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == requested))
            if source is None or device is None:
                raise HTTPException(status_code=404, detail="Mapping или Agent больше недоступен")
            source_payload = self._mapping_profile_payload(session, source, include_inventory=False, include_fields=True)
            source_fields = list(source_payload.get("fields") or [])
            target_fingerprint = str(device.project_fingerprint or "")

            if str(source.project_fingerprint or "") == target_fingerprint:
                now = _utcnow()
                for other in session.scalars(select(VmixMappingProfile).where(and_(
                    VmixMappingProfile.project_fingerprint == target_fingerprint,
                    VmixMappingProfile.active.is_(True),
                    VmixMappingProfile.id != source.id,
                ))):
                    other.active = False
                    other.updated_by = user.login
                    other.updated_at = now
                source.active = True
                source.updated_by = user.login
                source.updated_at = now
                runtime = source
                report = {
                    "total": len(source_fields), "mapped": len(source_fields), "skipped": 0,
                    "matched_by": {"key": len(source_fields), "title": 0, "number": 0},
                    "skipped_items": [], "reused": True,
                }
            else:
                runtime, report = self._upsert_runtime_mapping_for_device(
                    session, source=source, source_fields=source_fields, device=device, user=user,
                )
            runtime_id = runtime.id

        await self._broadcast_mapping_for_fingerprint(target_fingerprint)
        applied = await self.apply_mapping_to_device(requested, reason="mapping_config_selected")
        return {
            "ok": True,
            "device_id": requested,
            "profile": {"id": source_payload.get("id"), "name": source_payload.get("name"), "version": source_payload.get("version")},
            "runtime_profile_id": runtime_id,
            "report": report,
            "applied": applied,
        }

    async def export_mapping_profile(self, profile_id: int) -> dict[str, Any]:
        with self.database.session() as session:
            profile = session.get(VmixMappingProfile, profile_id)
            if profile is None:
                raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
            payload = self._mapping_profile_payload(session, profile, include_inventory=True, include_fields=True)
        fields = []
        for row in payload.get("fields") or []:
            fields.append({key: value for key, value in row.items() if key != "id"})
        inventory = payload.get("inventory") if isinstance(payload.get("inventory"), dict) else {}
        return {
            "format": "hockey-vmix-mapping",
            "schema_version": 1,
            "exported_at": _utcnow().isoformat(),
            "profile": {
                "name": payload.get("name") or "",
                "description": payload.get("description") or "",
                "source_project_fingerprint": payload.get("project_fingerprint") or "",
                "source_inventory": {
                    "input_count": inventory.get("input_count", 0),
                    "field_count": inventory.get("field_count", 0),
                    "vmix_version": inventory.get("vmix_version", ""),
                },
                "fields": fields,
            },
        }

    async def copy_mapping_profile_to_device(self, profile_id: int, payload: Any, user: HockeyUser) -> dict[str, Any]:
        device_id = self.normalise_device_id(payload.device_id)
        with self.database.session() as session:
            source = session.get(VmixMappingProfile, profile_id)
            if source is None:
                raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
            device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if device is None or not device.project_fingerprint or not str(device.project_inventory_json or "").strip():
                raise HTTPException(status_code=409, detail="Выбранный Agent ещё не передал структуру vMix")
            source_payload = self._mapping_profile_payload(session, source, include_inventory=False, include_fields=True)
            target_fingerprint = str(device.project_fingerprint or "")
            if str(source.project_fingerprint or "") == target_fingerprint:
                other = session.scalar(
                    select(VmixMappingProfile)
                    .where(and_(
                        VmixMappingProfile.project_fingerprint == target_fingerprint,
                        VmixMappingProfile.active.is_(True),
                        VmixMappingProfile.id != source.id,
                    ))
                    .order_by(desc(VmixMappingProfile.updated_at), desc(VmixMappingProfile.id))
                )
                if other is not None and not bool(payload.replace_existing):
                    raise HTTPException(status_code=409, detail=f"Для этого vMix уже активен Mapping «{other.name}». Разрешите замену при переносе.")
                if other is not None:
                    other.active = False
                    other.updated_by = user.login
                    other.updated_at = _utcnow()
                source.active = True
                source.updated_by = user.login
                source.updated_at = _utcnow()
                result_profile = self._mapping_profile_payload(session, source, include_inventory=True, include_fields=True)
                report = {"total": len(source_payload.get("fields") or []), "mapped": len(source_payload.get("fields") or []), "skipped": 0, "matched_by": {"key": len(source_payload.get("fields") or []), "title": 0, "number": 0}, "skipped_items": [], "reused": True}
            else:
                fallback_name = f"{source.name} · {device.name or device.hostname or device.device_uuid}"
                created, report = self._create_portable_profile_for_device(
                    session, device=device, fields=list(source_payload.get("fields") or []),
                    name=str(payload.name or "").strip() or fallback_name, description=source.description, user=user,
                    replace_existing=bool(payload.replace_existing),
                )
                result_profile = self._mapping_profile_payload(session, created, include_inventory=True, include_fields=True)
        await self._broadcast_mapping_for_fingerprint(target_fingerprint)
        applied = None
        if bool(payload.apply_now):
            applied = await self.apply_mapping_to_device(device_id, reason="portable_mapping_copy")
        return {"ok": True, "profile": result_profile, "report": report, "applied": applied}

    async def import_mapping_profile_to_device(self, payload: Any, user: HockeyUser) -> dict[str, Any]:
        device_id = self.normalise_device_id(payload.device_id)
        document = dict(payload.document or {})
        if document.get("format") not in {None, "", "hockey-vmix-mapping"}:
            raise HTTPException(status_code=422, detail="Это не файл Hockey vMix Mapping")
        try:
            schema_version = int(document.get("schema_version") or 1)
        except (TypeError, ValueError):
            raise HTTPException(status_code=422, detail="Некорректная версия файла Mapping")
        if schema_version != 1:
            raise HTTPException(status_code=422, detail="Версия файла Mapping пока не поддерживается")
        profile_doc = document.get("profile") if isinstance(document.get("profile"), dict) else document
        fields = profile_doc.get("fields") if isinstance(profile_doc, dict) and isinstance(profile_doc.get("fields"), list) else []
        if not fields:
            raise HTTPException(status_code=422, detail="В файле Mapping нет связей")
        with self.database.session() as session:
            device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if device is None or not device.project_fingerprint or not str(device.project_inventory_json or "").strip():
                raise HTTPException(status_code=409, detail="Выбранный Agent ещё не передал структуру vMix")
            target_fingerprint = str(device.project_fingerprint or "")
            imported_name = str(payload.name or "").strip() or str(profile_doc.get("name") or "Imported Mapping").strip()
            created, report = self._create_portable_profile_for_device(
                session, device=device, fields=fields, name=imported_name,
                description=str(profile_doc.get("description") or ""), user=user,
                replace_existing=bool(payload.replace_existing),
            )
            result_profile = self._mapping_profile_payload(session, created, include_inventory=True, include_fields=True)
        await self._broadcast_mapping_for_fingerprint(target_fingerprint)
        applied = None
        if bool(payload.apply_now):
            applied = await self.apply_mapping_to_device(device_id, reason="portable_mapping_import")
        return {"ok": True, "profile": result_profile, "report": report, "applied": applied}

    async def list_mapping_profiles(self) -> dict[str, Any]:
        with self.database.session() as session:
            rows = list(session.scalars(
                select(VmixMappingProfile)
                .where(~VmixMappingProfile.name.like("__AUTO_MAPPING__%"))
                .order_by(desc(VmixMappingProfile.updated_at), VmixMappingProfile.name)
            ))
            items = [self._mapping_profile_payload(session, row, include_inventory=False, include_fields=False) for row in rows]
            for item, row in zip(items, rows):
                item["field_count"] = len(list(session.scalars(select(VmixMappingField.id).where(VmixMappingField.profile_id == row.id))))
        return {"profiles": items}

    async def get_mapping_profile(self, profile_id: int) -> dict[str, Any]:
        with self.database.session() as session:
            row = session.get(VmixMappingProfile, profile_id)
            if row is None:
                raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
            return self._mapping_profile_payload(session, row, include_inventory=True, include_fields=True)

    async def create_mapping_profile(self, payload: Any, user: HockeyUser) -> dict[str, Any]:
        name = str(payload.name or "").strip()
        device_id = self.normalise_device_id(payload.device_id)
        if not name:
            raise HTTPException(status_code=422, detail="Укажите название mapping-профиля")
        now = _utcnow()
        with self.database.session() as session:
            if session.scalar(select(VmixMappingProfile).where(VmixMappingProfile.name == name)) is not None:
                raise HTTPException(status_code=409, detail="Mapping с таким названием уже существует")
            device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if device is None or not device.project_fingerprint or not device.project_inventory_json:
                raise HTTPException(status_code=409, detail="Agent ещё не передал структуру текущего vMix")
            existing = session.scalar(select(VmixMappingProfile).where(and_(VmixMappingProfile.project_fingerprint == device.project_fingerprint, VmixMappingProfile.active.is_(True))))
            if existing is not None:
                raise HTTPException(status_code=409, detail=f"Для этого проекта уже назначен mapping «{existing.name}»")
            row = VmixMappingProfile(
                name=name[:200],
                description=str(payload.description or "")[:4000],
                project_fingerprint=device.project_fingerprint,
                inventory_json=device.project_inventory_json,
                version=1,
                active=True,
                created_by=user.login,
                updated_by=user.login,
                created_at=now,
                updated_at=now,
            )
            session.add(row)
            session.flush()
            result = self._mapping_profile_payload(session, row, include_inventory=True, include_fields=True)
        await self._broadcast_mapping_for_fingerprint(result["project_fingerprint"])
        return result

    async def duplicate_mapping_profile(self, profile_id: int, user: HockeyUser) -> dict[str, Any]:
        """Create an independent editable copy of a visible Mapping config.

        The copy keeps the source inventory, all field links and rules, but starts
        inactive so it cannot unexpectedly replace the live Mapping for the same
        vMix project. It can then be rescanned against another Agent/project and
        edited without touching the original config.
        """
        now = _utcnow()
        with self.database.session() as session:
            source = session.get(VmixMappingProfile, profile_id)
            if source is None or self._runtime_mapping_source_id(source):
                raise HTTPException(status_code=404, detail="Mapping-конфиг не найден")

            copy_name = self._portable_profile_name(
                session,
                f"{str(source.name or 'Mapping').strip()} — копия",
                "Mapping — копия",
            )
            clone = VmixMappingProfile(
                name=copy_name,
                description=str(source.description or ""),
                project_fingerprint=str(source.project_fingerprint or ""),
                inventory_json=str(source.inventory_json or "{}"),
                version=1,
                active=False,
                created_by=user.login,
                updated_by=user.login,
                created_at=now,
                updated_at=now,
            )
            session.add(clone)
            session.flush()

            source_fields = list(session.scalars(
                select(VmixMappingField)
                .where(VmixMappingField.profile_id == source.id)
                .order_by(VmixMappingField.sort_order, VmixMappingField.id)
            ))
            for item in source_fields:
                session.add(VmixMappingField(
                    profile_id=clone.id,
                    graphic=str(item.graphic or "")[:100],
                    data_key=str(item.data_key or "")[:200],
                    vmix_input_key=str(item.vmix_input_key or "")[:128],
                    vmix_input_number=str(item.vmix_input_number or "")[:32],
                    vmix_input_title=str(item.vmix_input_title or "")[:300],
                    vmix_field=str(item.vmix_field or "")[:300],
                    field_type=str(item.field_type or "text")[:32],
                    rule_json=str(item.rule_json or "{}")[:12000],
                    enabled=bool(item.enabled),
                    sort_order=int(item.sort_order or 0),
                ))
            session.flush()
            result = self._mapping_profile_payload(session, clone, include_inventory=True, include_fields=True)
            result["copied_from_profile_id"] = source.id
            result["copied_fields"] = len(source_fields)
        return result

    async def update_mapping_profile(self, profile_id: int, payload: Any, user: HockeyUser) -> dict[str, Any]:
        now = _utcnow()
        with self.database.session() as session:
            row = session.get(VmixMappingProfile, profile_id)
            if row is None:
                raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
            name = str(payload.name or "").strip()
            if not name:
                raise HTTPException(status_code=422, detail="Укажите название mapping-профиля")
            duplicate = session.scalar(select(VmixMappingProfile).where(and_(VmixMappingProfile.name == name, VmixMappingProfile.id != profile_id)))
            if duplicate is not None:
                raise HTTPException(status_code=409, detail="Mapping с таким названием уже существует")
            row.name = name[:200]
            row.description = str(payload.description or "")[:4000]
            row.active = bool(payload.active)
            # The web editor saves metadata and field links as one operation.
            # Version is incremented by replace_mapping_fields(), so one click
            # on Save produces exactly one new mapping version.
            row.updated_by = user.login
            row.updated_at = now
            result = self._mapping_profile_payload(session, row, include_inventory=True, include_fields=True)
        await self._broadcast_mapping_for_fingerprint(result["project_fingerprint"])
        return result

    @classmethod
    def _mapping_inventory_additions(cls, old_inventory: dict[str, Any], new_inventory: dict[str, Any]) -> dict[str, int]:
        """Count newly discovered Inputs/fields while tolerating stable-key changes.

        Existing Inputs are resolved with the same conservative identity order used
        by portable Mapping rebinding: key, unique title, then unique number. This is
        only a UI report; it never changes or guesses Mapping links.
        """
        old_inputs = old_inventory.get("inputs") if isinstance(old_inventory, dict) and isinstance(old_inventory.get("inputs"), list) else []
        new_inputs = new_inventory.get("inputs") if isinstance(new_inventory, dict) and isinstance(new_inventory.get("inputs"), list) else []
        by_key: dict[str, dict[str, Any]] = {}
        by_title: dict[str, list[dict[str, Any]]] = {}
        by_number: dict[str, list[dict[str, Any]]] = {}
        for item in new_inputs:
            if not isinstance(item, dict):
                continue
            key = str(item.get("key") or "").strip()
            title = cls._portable_text(item.get("title"))
            number = str(item.get("number") or "").strip()
            if key:
                by_key[key] = item
            if title:
                by_title.setdefault(title, []).append(item)
            if number:
                by_number.setdefault(number, []).append(item)

        used: set[int] = set()
        added_fields = 0
        for old in old_inputs:
            if not isinstance(old, dict):
                continue
            target = None
            key = str(old.get("key") or "").strip()
            title = cls._portable_text(old.get("title"))
            number = str(old.get("number") or "").strip()
            if key:
                target = by_key.get(key)
            if target is None and title and len(by_title.get(title, [])) == 1:
                target = by_title[title][0]
            if target is None and number and len(by_number.get(number, [])) == 1:
                target = by_number[number][0]
            if target is None:
                continue
            used.add(id(target))
            old_names = {cls._portable_text(field.get("name")) for field in (old.get("fields") or []) if isinstance(field, dict) and str(field.get("name") or "").strip()}
            new_names = {cls._portable_text(field.get("name")) for field in (target.get("fields") or []) if isinstance(field, dict) and str(field.get("name") or "").strip()}
            added_fields += len(new_names - old_names)

        added_inputs = [item for item in new_inputs if isinstance(item, dict) and id(item) not in used]
        added_fields += sum(len([field for field in (item.get("fields") or []) if isinstance(field, dict) and str(field.get("name") or "").strip()]) for item in added_inputs)
        return {"new_inputs": len(added_inputs), "new_fields": added_fields}

    async def refresh_mapping_inventory(self, profile_id: int, device_id: str, user: HockeyUser) -> dict[str, Any]:
        """Refresh vMix structure without resetting existing Mapping links.

        New titles/fields only expand the inventory. Existing links are rebound to
        the freshly scanned project by Input key -> unique title -> unique number,
        and the exact GT field name must still exist. A link that cannot currently
        be resolved is kept unchanged in the database instead of being deleted.
        """
        device_id = self.normalise_device_id(device_id)
        now = _utcnow()
        with self.database.session() as session:
            row = session.get(VmixMappingProfile, profile_id)
            device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if row is None:
                raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
            if device is None or not device.project_fingerprint or not device.project_inventory_json:
                raise HTTPException(status_code=409, detail="Устройство не передало структуру vMix")
            conflicting = session.scalar(select(VmixMappingProfile).where(and_(VmixMappingProfile.project_fingerprint == device.project_fingerprint, VmixMappingProfile.id != row.id, VmixMappingProfile.active.is_(True))))
            if conflicting is not None:
                # Build 74 may have created a hidden runtime adaptation of this same
                # visible config for the Agent. An explicit rescan means the admin now
                # wants the visible config itself to adopt that concrete vMix structure.
                if self._runtime_mapping_source_id(conflicting) == row.id:
                    conflicting.active = False
                    conflicting.updated_by = user.login
                    conflicting.updated_at = now
                elif row.active:
                    raise HTTPException(status_code=409, detail=f"Эта структура уже используется mapping «{conflicting.name}»")
                # An inactive visible copy is a draft. It may adopt the same vMix
                # inventory as another live config so the admin can reuse the links,
                # rescan against another project and edit the copy independently.

            try:
                old_inventory = json.loads(row.inventory_json or "{}")
                if not isinstance(old_inventory, dict):
                    old_inventory = {}
            except Exception:
                old_inventory = {}
            try:
                new_inventory = json.loads(device.project_inventory_json or "{}")
                if not isinstance(new_inventory, dict):
                    new_inventory = {}
            except Exception:
                new_inventory = {}

            link_rows = list(session.scalars(
                select(VmixMappingField)
                .where(VmixMappingField.profile_id == profile_id)
                .order_by(VmixMappingField.sort_order, VmixMappingField.id)
            ))
            matched_by = {"key": 0, "title": 0, "number": 0}
            preserved = 0
            unresolved = 0
            unresolved_items: list[dict[str, Any]] = []
            for field_row in link_rows:
                raw = {
                    "graphic": field_row.graphic,
                    "data_key": field_row.data_key,
                    "vmix_input_key": field_row.vmix_input_key,
                    "vmix_input_number": field_row.vmix_input_number,
                    "vmix_input_title": field_row.vmix_input_title,
                    "vmix_field": field_row.vmix_field,
                    "field_type": field_row.field_type,
                    "rule": _mapping_rule_payload(field_row.rule_json),
                    "enabled": field_row.enabled,
                }
                rebound, report = self._rebind_portable_mapping_fields([raw], new_inventory)
                if rebound:
                    target = rebound[0]
                    field_row.vmix_input_key = str(target.get("vmix_input_key") or "")[:128]
                    field_row.vmix_input_number = str(target.get("vmix_input_number") or "")[:32]
                    field_row.vmix_input_title = str(target.get("vmix_input_title") or "")[:300]
                    field_row.vmix_field = str(target.get("vmix_field") or field_row.vmix_field)[:300]
                    preserved += 1
                    for method in matched_by:
                        matched_by[method] += int((report.get("matched_by") or {}).get(method, 0) or 0)
                else:
                    # Keep the old link. This protects operator work from a temporary
                    # incomplete scan and lets it recover automatically if the target
                    # returns on the next refresh.
                    unresolved += 1
                    if len(unresolved_items) < 100:
                        skipped = (report.get("skipped_items") or [{}])[0]
                        unresolved_items.append({
                            "data_key": field_row.data_key,
                            "input": field_row.vmix_input_title or field_row.vmix_input_key or field_row.vmix_input_number,
                            "vmix_field": field_row.vmix_field,
                            "reason": skipped.get("reason", "not_found"),
                        })

            additions = self._mapping_inventory_additions(old_inventory, new_inventory)
            old_fingerprint = row.project_fingerprint
            row.project_fingerprint = device.project_fingerprint
            row.inventory_json = device.project_inventory_json
            row.version = int(row.version or 0) + 1
            row.updated_by = user.login
            row.updated_at = now
            session.flush()
            result = self._mapping_profile_payload(session, row, include_inventory=True, include_fields=True)
            result["refresh_report"] = {
                "total_links": len(link_rows),
                "preserved": preserved,
                "unresolved": unresolved,
                "matched_by": matched_by,
                "unresolved_items": unresolved_items,
                **additions,
            }
        if old_fingerprint and old_fingerprint != result["project_fingerprint"]:
            await self._broadcast_mapping_for_fingerprint(old_fingerprint)
        await self._broadcast_mapping_for_fingerprint(result["project_fingerprint"])
        return result

    async def replace_mapping_fields(self, profile_id: int, payload: Any, user: HockeyUser) -> dict[str, Any]:
        now = _utcnow()
        with self.database.session() as session:
            profile = session.get(VmixMappingProfile, profile_id)
            if profile is None:
                raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
            for row in list(session.scalars(select(VmixMappingField).where(VmixMappingField.profile_id == profile_id))):
                session.delete(row)
            session.flush()
            for index, item in enumerate(payload.fields[:2000]):
                data_key = str(item.data_key or "").strip()
                vmix_field = str(item.vmix_field or "").strip()
                if not data_key or not vmix_field:
                    continue
                session.add(VmixMappingField(
                    profile_id=profile_id,
                    graphic=str(item.graphic or "")[:100],
                    data_key=data_key[:200],
                    vmix_input_key=str(item.vmix_input_key or "")[:128],
                    vmix_input_number=("" if (str(item.vmix_input_key or "").strip() or str(item.vmix_input_title or "").strip()) else str(item.vmix_input_number or "")[:32]),
                    vmix_input_title=str(item.vmix_input_title or "")[:300],
                    vmix_field=vmix_field[:300],
                    field_type=str(item.field_type or "text")[:32],
                    rule_json=json.dumps(dict(getattr(item, "rule", {}) or {}), ensure_ascii=False, separators=(",", ":"))[:12000],
                    enabled=bool(item.enabled),
                    sort_order=index,
                ))
            profile.version = int(profile.version or 0) + 1
            profile.updated_by = user.login
            profile.updated_at = now
            session.flush()
            result = self._mapping_profile_payload(session, profile, include_inventory=True, include_fields=True)
        await self._broadcast_mapping_for_fingerprint(result["project_fingerprint"])
        return result

    async def delete_mapping_profile(self, profile_id: int) -> dict[str, Any]:
        with self.database.session() as session:
            profile = session.get(VmixMappingProfile, profile_id)
            if profile is None:
                raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
            fingerprint = profile.project_fingerprint
            runtime_prefix = f"__AUTO_MAPPING__{profile_id}__%"
            runtime_profiles = list(session.scalars(select(VmixMappingProfile).where(VmixMappingProfile.name.like(runtime_prefix))))
            for runtime in runtime_profiles:
                session.execute(delete(VmixMappingField).where(VmixMappingField.profile_id == runtime.id))
                session.delete(runtime)
            for field in list(session.scalars(select(VmixMappingField).where(VmixMappingField.profile_id == profile_id))):
                session.delete(field)
            session.delete(profile)
        if fingerprint:
            await self._broadcast_mapping_for_fingerprint(fingerprint)
        return {"ok": True, "id": profile_id}

    async def _broadcast_mapping_for_fingerprint(self, fingerprint: str) -> None:
        if not fingerprint:
            return
        profile_payload: dict[str, Any] | None = None
        device_ids: list[str] = []
        with self.database.session() as session:
            profile = session.scalar(
                select(VmixMappingProfile)
                .where(and_(VmixMappingProfile.project_fingerprint == fingerprint, VmixMappingProfile.active.is_(True)))
                .order_by(desc(VmixMappingProfile.updated_at))
            )
            if profile is not None:
                profile_payload = self._mapping_profile_payload(session, profile, include_inventory=False, include_fields=True)
                # Include devices recovered by stable-key compatibility, not only
                # devices whose whole-project fingerprint is byte-for-byte equal.
                for row in session.scalars(select(VmixDevice).where(VmixDevice.project_fingerprint != "")):
                    resolved = self._active_mapping_profile_for_device(session, row)
                    if resolved is not None and resolved.id == profile.id:
                        device_ids.append(str(row.device_uuid))
            else:
                device_ids = [
                    str(row.device_uuid)
                    for row in session.scalars(select(VmixDevice).where(VmixDevice.project_fingerprint == fingerprint))
                ]
        for device_id in dict.fromkeys(device_ids):
            if profile_payload is None:
                await self.send(device_id, {"type": "mapping.missing", "project_fingerprint": fingerprint})
            else:
                delivered = await self.send(device_id, {"type": "mapping.assigned", **profile_payload})
                if delivered:
                    await self.apply_mapping_to_device(device_id, reason="mapping_loaded")

    async def probe_device(self, device_id: str, user: HockeyUser) -> dict[str, Any]:
        device_id = self.normalise_device_id(device_id)
        with self.database.session() as session:
            row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if row is None or row.wfl_user_id != user.id:
                raise HTTPException(status_code=404, detail="Устройство не прикреплено к вашему аккаунту")
        delivered = await self.send(device_id, {"type": "vmix.probe", "request_id": secrets.token_urlsafe(8)})
        if not delivered:
            raise HTTPException(status_code=409, detail="Agent сейчас offline")
        return {"ok": True, "device_id": device_id}

    @staticmethod
    def _assignment_payload(row: VmixAssignment, *, device_id: str) -> dict[str, Any]:
        return {
            "assignment_id": row.assignment_key,
            "device_id": device_id,
            "game_id": row.game_external_id,
            "tournament_id": row.tournament_external_id,
            "operator_session_token": row.operator_session_token,
            "created_at": row.created_at.isoformat(),
        }

    def _device_payload(self, row: VmixDevice, *, viewer_user_id: str) -> dict[str, Any]:
        paired = bool(row.wfl_user_id)
        paired_to_me = bool(viewer_user_id and row.wfl_user_id == viewer_user_id)
        mapping = None
        if row.project_fingerprint or str(row.project_inventory_json or "").strip():
            with self.database.session() as mapping_session:
                managed = mapping_session.get(VmixDevice, row.id)
                profile = self._active_mapping_profile_for_device(mapping_session, managed) if managed is not None else None
                if profile is not None:
                    mapping = {"id": profile.id, "name": profile.name, "version": profile.version}
        return {
            "device_id": row.device_uuid,
            "name": row.name or row.hostname or row.device_uuid,
            "hostname": row.hostname,
            "agent_version": row.agent_version,
            "paired": paired,
            "paired_to_me": paired_to_me,
            "active_for_account": bool(row.is_active_for_account and paired_to_me),
            "owner": row.login_snapshot if paired_to_me else ("Другой оператор" if paired else ""),
            "vmix_connected": bool(row.vmix_connected),
            "vmix_version": row.vmix_version,
            "vmix_url": row.vmix_url,
            "current_match_id": row.current_match_external_id if paired_to_me else "",
            "assignment_id": row.current_assignment_key if paired_to_me else "",
            "last_seen": row.last_seen_at.isoformat() if row.last_seen_at else "",
            "last_error": row.last_error if paired_to_me else "",
            "project_fingerprint": row.project_fingerprint,
            "project_input_count": row.project_input_count,
            "project_field_count": row.project_field_count,
            "mapping": mapping,
            "online": row.device_uuid in self._live,
        }


class PairDevicePayload(BaseModel):
    make_active: bool = True


class TestSetTextPayload(BaseModel):
    input: str = Field(min_length=1, max_length=300)
    selected_name: str = Field(min_length=1, max_length=300)
    value: str = Field(default="", max_length=4000)


class RuntimeVmixCommandPayload(BaseModel):
    model_config = ConfigDict(extra="allow")

    Function: str = Field(min_length=1, max_length=128)
    Input: str = Field(default="", max_length=300)
    Value: Any = ""
    SelectedName: str = Field(default="", max_length=300)
    Duration: str = Field(default="", max_length=64)
    Mix: str = Field(default="", max_length=32)


class RuntimeVmixSequencePayload(BaseModel):
    commands: list[RuntimeVmixCommandPayload] = Field(default_factory=list, min_length=1, max_length=80)
    device_id: str = Field(default="", max_length=128)
    session_token: str = Field(default="", max_length=128)
    sequence_id: str = Field(default="", max_length=160)
    sequence_name: str = Field(default="", max_length=300)
    button_id: str = Field(default="", max_length=160)
    delivery_mode: str = Field(default="", max_length=32)


class SelectSessionDevicePayload(BaseModel):
    session_token: str = Field(default="", max_length=128)
    # Runtime match switching can assign immediately and apply Mapping only
    # after the new roster has been loaded. Existing Agent UI keeps True.
    apply_mapping: bool = True


class MappingTestValuePayload(BaseModel):
    device_id: str = Field(min_length=6, max_length=128)
    input: str = Field(min_length=1, max_length=300)
    selected_name: str = Field(min_length=1, max_length=300)
    value: str = Field(default="", max_length=4000)
    field_type: str = Field(default="text", max_length=32)


class MappingTestBatchCommandPayload(BaseModel):
    input: str = Field(min_length=1, max_length=300)
    selected_name: str = Field(min_length=1, max_length=300)
    value: str = Field(default="", max_length=4000)
    field_type: str = Field(default="text", max_length=32)
    function: str = Field(default="", max_length=64)
    data_key: str = Field(default="", max_length=200)


class MappingTestBatchPayload(BaseModel):
    device_id: str = Field(min_length=6, max_length=128)
    commands: list[MappingTestBatchCommandPayload] = Field(default_factory=list, min_length=1, max_length=500)


class MappingProfileCreatePayload(BaseModel):
    name: str = Field(min_length=1, max_length=200)
    device_id: str = Field(min_length=6, max_length=128)
    description: str = Field(default="", max_length=4000)


class MappingProfileUpdatePayload(BaseModel):
    name: str = Field(min_length=1, max_length=200)
    description: str = Field(default="", max_length=4000)
    active: bool = True


class MappingInventoryRefreshPayload(BaseModel):
    device_id: str = Field(min_length=6, max_length=128)


class MappingFieldPayload(BaseModel):
    graphic: str = Field(default="", max_length=100)
    data_key: str = Field(min_length=1, max_length=200)
    vmix_input_key: str = Field(default="", max_length=128)
    vmix_input_number: str = Field(default="", max_length=32)
    vmix_input_title: str = Field(default="", max_length=300)
    vmix_field: str = Field(min_length=1, max_length=300)
    field_type: str = Field(default="text", max_length=32)
    rule: dict[str, Any] = Field(default_factory=dict)
    enabled: bool = True


class MappingFieldsReplacePayload(BaseModel):
    fields: list[MappingFieldPayload] = Field(default_factory=list, max_length=2000)


class MappingProfileUsePayload(BaseModel):
    device_id: str = Field(default="", max_length=128)
    session_token: str = Field(default="", max_length=128)


class MappingProfileCopyPayload(BaseModel):
    device_id: str = Field(min_length=6, max_length=128)
    name: str = Field(default="", max_length=200)
    replace_existing: bool = False
    apply_now: bool = True


class MappingPortableImportPayload(BaseModel):
    device_id: str = Field(min_length=6, max_length=128)
    document: dict[str, Any] = Field(default_factory=dict)
    name: str = Field(default="", max_length=200)
    replace_existing: bool = False
    apply_now: bool = True


class ContextVariablePayload(BaseModel):
    key: str = Field(min_length=1, max_length=128)
    label: str = Field(min_length=1, max_length=200)
    category: str = Field(default="Пользовательские", max_length=100)
    description: str = Field(default="", max_length=4000)
    value_type: str = Field(default="id", max_length=32)
    entity_type: str = Field(default="", max_length=32)
    scope: str = Field(default="match", max_length=32)
    source_type: str = Field(default="manual", max_length=32)
    default_value: str = Field(default="", max_length=4000)
    enabled: bool = True
    sort_order: int = 1000


class ContextValuePayload(BaseModel):
    value: str = Field(default="", max_length=8000)
    context: dict[str, Any] = Field(default_factory=dict)


class ContextValuesPayload(BaseModel):
    values: dict[str, Any] = Field(default_factory=dict)
    context: dict[str, Any] = Field(default_factory=dict)


class SqlDataSourcePayload(BaseModel):
    code: str = Field(min_length=1, max_length=100)
    name: str = Field(min_length=1, max_length=200)
    category: str = Field(default="Данные", max_length=100)
    description: str = Field(default="", max_length=4000)
    sql_text: str = Field(min_length=1, max_length=50000)
    field_metadata: dict[str, Any] = Field(default_factory=dict)
    enabled: bool = True
    auto_refresh_enabled: bool = False
    refresh_interval_ms: int = Field(default=1000, ge=1000, le=3600000)
    sort_order: int = 1000


class MappingDataContextPayload(BaseModel):
    context: dict[str, Any] = Field(default_factory=dict)


class MappingSqlPreviewPayload(BaseModel):
    sql_text: str = Field(min_length=1, max_length=50000)
    context: dict[str, Any] = Field(default_factory=dict)


class PreparedTitleCreatePayload(BaseModel):
    name: str = Field(default="", max_length=200)
    device_id: str = Field(default="", max_length=128)
    session_token: str = Field(default="", max_length=128)
    source_input_key: str = Field(default="", max_length=128)
    source_input_number: str = Field(default="", max_length=32)
    source_input_title: str = Field(default="", max_length=300)
    source_kind: str = Field(default="manual", max_length=32)
    source_ref: str = Field(default="", max_length=128)
    field_values: dict[str, Any] = Field(default_factory=dict)


class PreparedTitleUpdatePayload(BaseModel):
    name: str = Field(default="", max_length=200)
    device_id: str = Field(default="", max_length=128)
    session_token: str = Field(default="", max_length=128)
    field_values: dict[str, Any] = Field(default_factory=dict)


class PreparedTitlePreviewPayload(BaseModel):
    device_id: str = Field(default="", max_length=128)
    session_token: str = Field(default="", max_length=128)


class AgentHelloPayload(BaseModel):
    model_config = ConfigDict(extra="ignore")
    type: str = "hello"
    protocol: int = Field(default=AGENT_PROTOCOL_VERSION)
    device_id: str
    device_secret: str
    device_name: str = ""
    hostname: str = ""
    agent_version: str = ""
    vmix: dict[str, Any] = Field(default_factory=dict)


def create_hockey_agent_router(
    hub: VmixAgentHub,
    *,
    auth_dependency: Callable[..., Any],
    admin_dependency: Callable[..., Any] | None = None,
) -> APIRouter:
    router = APIRouter(tags=["Hockey vMix Agent"])
    admin = [Depends(admin_dependency)] if admin_dependency else []

    @router.get("/api/hockey/agents/devices")
    async def list_devices(user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        return await hub.list_for_user(user)

    @router.get("/api/hockey/agents/vmix-inventory")
    async def operator_vmix_inventory(
        device_id: str = Query("", max_length=128),
        user: HockeyUser = Depends(auth_dependency),
    ) -> dict[str, Any]:
        return await hub.vmix_inventory_for_user(user, device_id=device_id)

    @router.get("/api/hockey/prepared-titles")
    async def prepared_titles_list(
        device_id: str = Query("", max_length=128),
        game_id: str = Query("", max_length=64),
        user: HockeyUser = Depends(auth_dependency),
    ) -> dict[str, Any]:
        return await hub.list_prepared_titles(user, device_id=device_id, game_id=game_id)

    @router.get("/api/hockey/prepared-titles/mapping-sources")
    async def prepared_titles_mapping_sources(
        device_id: str = Query("", max_length=128),
        panel_id: str = Query("", max_length=64),
        user: HockeyUser = Depends(auth_dependency),
    ) -> dict[str, Any]:
        return await hub.prepared_mapping_sources(user, device_id=device_id, panel_id=panel_id)

    @router.get("/api/hockey/prepared-titles/mapping-snapshot")
    async def prepared_titles_mapping_snapshot(
        device_id: str = Query("", max_length=128),
        input_key: str = Query("", max_length=128),
        input_number: str = Query("", max_length=32),
        input_title: str = Query("", max_length=300),
        panel_id: str = Query("", max_length=64),
        user: HockeyUser = Depends(auth_dependency),
    ) -> dict[str, Any]:
        return await hub.prepared_mapping_snapshot(
            user, device_id=device_id, input_key=input_key, input_number=input_number, input_title=input_title, panel_id=panel_id
        )

    @router.post("/api/hockey/prepared-titles")
    async def prepared_title_create(
        payload: PreparedTitleCreatePayload,
        user: HockeyUser = Depends(auth_dependency),
    ) -> dict[str, Any]:
        return await hub.create_prepared_title(user, payload)

    @router.put("/api/hockey/prepared-titles/{prepared_id}")
    async def prepared_title_update(
        prepared_id: int,
        payload: PreparedTitleUpdatePayload,
        user: HockeyUser = Depends(auth_dependency),
    ) -> dict[str, Any]:
        return await hub.update_prepared_title(prepared_id, user, payload)

    @router.post("/api/hockey/prepared-titles/{prepared_id}/preview")
    async def prepared_title_preview(
        prepared_id: int,
        payload: PreparedTitlePreviewPayload,
        user: HockeyUser = Depends(auth_dependency),
    ) -> dict[str, Any]:
        return await hub.preview_prepared_title(prepared_id, user, payload)

    @router.delete("/api/hockey/prepared-titles/{prepared_id}")
    async def prepared_title_delete(
        prepared_id: int,
        user: HockeyUser = Depends(auth_dependency),
    ) -> dict[str, Any]:
        return await hub.delete_prepared_title(prepared_id, user)

    @router.post("/api/hockey/agents/devices/{device_id}/pair")
    async def pair_device(
        device_id: str,
        payload: PairDevicePayload,
        user: HockeyUser = Depends(auth_dependency),
    ) -> dict[str, Any]:
        return await hub.pair_device(device_id, user, make_active=payload.make_active)

    @router.post("/api/hockey/agents/devices/{device_id}/activate")
    async def activate_device(
        device_id: str,
        user: HockeyUser = Depends(auth_dependency),
    ) -> dict[str, Any]:
        return await hub.activate_device(device_id, user)

    @router.post("/api/hockey/agents/devices/{device_id}/deactivate")
    async def deactivate_device(
        device_id: str,
        user: HockeyUser = Depends(auth_dependency),
    ) -> dict[str, Any]:
        return await hub.deactivate_device(device_id, user)

    @router.post("/api/hockey/agents/devices/{device_id}/select-session")
    async def select_session_device(
        device_id: str,
        payload: SelectSessionDevicePayload,
        user: HockeyUser = Depends(auth_dependency),
    ) -> dict[str, Any]:
        return await hub.select_device_for_session(
            device_id,
            user,
            session_token=payload.session_token,
            wait_for_mapping=bool(payload.apply_mapping),
        )

    @router.delete("/api/hockey/agents/devices/{device_id}/pair")
    async def unpair_device(
        device_id: str,
        user: HockeyUser = Depends(auth_dependency),
    ) -> dict[str, Any]:
        return await hub.unpair_device(device_id, user)

    @router.post("/api/hockey/agents/devices/{device_id}/probe")
    async def probe_device(
        device_id: str,
        user: HockeyUser = Depends(auth_dependency),
    ) -> dict[str, Any]:
        return await hub.probe_device(device_id, user)

    @router.post("/api/hockey/agents/devices/{device_id}/test-set-text")
    async def test_set_text(
        device_id: str,
        payload: TestSetTextPayload,
        user: HockeyUser = Depends(auth_dependency),
    ) -> dict[str, Any]:
        return await hub.test_set_text(
            device_id,
            user,
            input_ref=payload.input,
            selected_name=payload.selected_name,
            value=payload.value,
        )

    @router.post("/api/hockey/agents/devices/{device_id}/apply-mapping")
    async def apply_device_mapping(
        device_id: str,
        only_changed: bool = False,
        active_tab: str = "",
        source_code: str = "",
        user: HockeyUser = Depends(auth_dependency),
    ) -> dict[str, Any]:
        device_id = hub.normalise_device_id(device_id)
        with hub.database.session() as session:
            device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
            if device is None or device.wfl_user_id != user.id:
                raise HTTPException(status_code=404, detail="Устройство не прикреплено к вашему аккаунту")
            if not device.is_active_for_account:
                raise HTTPException(status_code=409, detail="Для этого Agent выключено получение данных")
        wanted_source = str(source_code or "").strip()
        source_codes = {wanted_source} if wanted_source else None
        result = await hub.apply_mapping_to_device(
            device_id,
            reason=("runtime_score_changed" if wanted_source == "score" else "runtime_strength_changed") if only_changed else "manual_apply",
            source_codes=source_codes,
            only_changed=only_changed,
            extra_context={"active_tab": str(active_tab or "").strip()} if str(active_tab or "").strip() else None,
        )
        # BUILD97: field-level vMix errors are returned as diagnostics instead of
        # being collapsed into a misleading HTTP 409 (e.g. "manual_apply").
        fatal_reasons = {"device_not_found", "device_not_paired", "no_match_assignment", "no_project_inventory", "vmix_not_connected", "mapping_missing"}
        if not result.get("ok") and str(result.get("reason") or "") in fatal_reasons:
            raise HTTPException(status_code=409, detail=str(result.get("reason")))
        return result

    @router.post("/api/hockey/agents/apply-active-mapping")
    async def apply_active_mapping(user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        result = await hub.apply_mapping_for_user(user, reason="language_or_runtime_changed")
        return result or {"ok": False, "reason": "no_active_device"}

    @router.post("/api/hockey/vmix/sequence")
    async def run_runtime_vmix_sequence(
        payload: RuntimeVmixSequencePayload,
        user: HockeyUser = Depends(auth_dependency),
    ) -> dict[str, Any]:
        commands = [item.model_dump(exclude_none=True) for item in payload.commands]
        return await hub.run_vmix_sequence_for_user(
            user,
            commands,
            device_id=payload.device_id,
            session_token=payload.session_token,
            sequence_id=payload.sequence_id,
            sequence_name=payload.sequence_name,
            button_id=payload.button_id,
            delivery_mode=payload.delivery_mode,
        )

    @router.get("/api/hockey/vmix/overlay-state")
    async def runtime_vmix_overlay_state(
        device_id: str = Query(default="", max_length=128),
        user: HockeyUser = Depends(auth_dependency),
    ) -> dict[str, Any]:
        return hub.runtime_overlay_state_for_user(user, device_id=device_id)

    @router.post("/api/hockey/admin/vmix-mapping/apply-all-active", dependencies=admin)
    async def admin_apply_all_active_mappings() -> dict[str, Any]:
        return await hub.apply_mapping_to_all_active_devices(reason="ui_language_changed")

    @router.post("/api/hockey/admin/vmix-mapping/apply/{device_id}", dependencies=admin)
    async def admin_apply_device_mapping(device_id: str) -> dict[str, Any]:
        result = await hub.apply_mapping_to_device(device_id, reason="admin_manual_apply")
        fatal_reasons = {"device_not_found", "device_not_paired", "no_match_assignment", "no_project_inventory", "vmix_not_connected", "mapping_missing"}
        if not result.get("ok") and str(result.get("reason") or "") in fatal_reasons:
            raise HTTPException(status_code=409, detail=str(result.get("reason")))
        return result

    @router.post("/api/hockey/admin/vmix-mapping/test-value", dependencies=admin)
    async def mapping_test_value(payload: MappingTestValuePayload) -> dict[str, Any]:
        return await hub.admin_test_mapping_value(
            payload.device_id,
            input_ref=payload.input,
            selected_name=payload.selected_name,
            value=payload.value,
            field_type=payload.field_type,
        )

    @router.post("/api/hockey/admin/vmix-mapping/test-batch", dependencies=admin)
    async def mapping_test_batch(payload: MappingTestBatchPayload) -> dict[str, Any]:
        return await hub.admin_test_mapping_batch(payload.device_id, payload.commands)

    @router.get("/api/hockey/admin/vmix-mapping/devices", dependencies=admin)
    async def mapping_devices() -> dict[str, Any]:
        return await hub.list_mapping_devices()

    @router.get("/api/hockey/admin/vmix-mapping/profiles", dependencies=admin)
    async def mapping_profiles() -> dict[str, Any]:
        return await hub.list_mapping_profiles()

    @router.get("/api/hockey/admin/vmix-mapping/profiles/{profile_id}", dependencies=admin)
    async def mapping_profile(profile_id: int) -> dict[str, Any]:
        return await hub.get_mapping_profile(profile_id)

    @router.post("/api/hockey/admin/vmix-mapping/profiles", dependencies=admin)
    async def mapping_profile_create(payload: MappingProfileCreatePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        return await hub.create_mapping_profile(payload, user)

    @router.post("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/use", dependencies=admin)
    async def mapping_profile_use(profile_id: int, payload: MappingProfileUsePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        return await hub.use_mapping_profile_for_user(profile_id, payload, user)

    @router.post("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/duplicate", dependencies=admin)
    async def mapping_profile_duplicate(profile_id: int, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        return await hub.duplicate_mapping_profile(profile_id, user)

    @router.get("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/export", dependencies=admin)
    async def mapping_profile_export(profile_id: int) -> dict[str, Any]:
        return await hub.export_mapping_profile(profile_id)

    @router.post("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/copy-to-device", dependencies=admin)
    async def mapping_profile_copy_to_device(profile_id: int, payload: MappingProfileCopyPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        return await hub.copy_mapping_profile_to_device(profile_id, payload, user)

    @router.post("/api/hockey/admin/vmix-mapping/import", dependencies=admin)
    async def mapping_profile_import(payload: MappingPortableImportPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        return await hub.import_mapping_profile_to_device(payload, user)

    @router.put("/api/hockey/admin/vmix-mapping/profiles/{profile_id}", dependencies=admin)
    async def mapping_profile_update(profile_id: int, payload: MappingProfileUpdatePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        return await hub.update_mapping_profile(profile_id, payload, user)

    @router.post("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/inventory", dependencies=admin)
    async def mapping_profile_inventory(profile_id: int, payload: MappingInventoryRefreshPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        return await hub.refresh_mapping_inventory(profile_id, payload.device_id, user)

    @router.put("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/fields", dependencies=admin)
    async def mapping_profile_fields(profile_id: int, payload: MappingFieldsReplacePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        return await hub.replace_mapping_fields(profile_id, payload, user)

    @router.delete("/api/hockey/admin/vmix-mapping/profiles/{profile_id}", dependencies=admin)
    async def mapping_profile_delete(profile_id: int) -> dict[str, Any]:
        return await hub.delete_mapping_profile(profile_id)

    @router.get("/api/hockey/admin/mapping-context/variables", dependencies=admin)
    async def mapping_context_variables() -> dict[str, Any]:
        return hub.mapping_data.list_variables()

    @router.post("/api/hockey/admin/mapping-context/variables", dependencies=admin)
    async def mapping_context_variable_create(payload: ContextVariablePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        return hub.mapping_data.create_variable(payload, user)

    @router.put("/api/hockey/admin/mapping-context/variables/{variable_id}", dependencies=admin)
    async def mapping_context_variable_update(variable_id: int, payload: ContextVariablePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        return hub.mapping_data.update_variable(variable_id, payload, user)

    @router.delete("/api/hockey/admin/mapping-context/variables/{variable_id}", dependencies=admin)
    async def mapping_context_variable_delete(variable_id: int) -> dict[str, Any]:
        return hub.mapping_data.delete_variable(variable_id)

    @router.get("/api/hockey/admin/mapping-data/sources", dependencies=admin)
    async def mapping_data_sources() -> dict[str, Any]:
        return hub.mapping_data.list_sources()

    @router.get("/api/hockey/admin/mapping-data/database-schema", dependencies=admin)
    async def mapping_database_schema() -> dict[str, Any]:
        return hub.mapping_data.database_schema_reference()

    @router.post("/api/hockey/admin/mapping-data/sources", dependencies=admin)
    async def mapping_data_source_create(payload: SqlDataSourcePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        return hub.mapping_data.create_source(payload, user)

    @router.put("/api/hockey/admin/mapping-data/sources/{source_id}", dependencies=admin)
    async def mapping_data_source_update(source_id: int, payload: SqlDataSourcePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        return hub.mapping_data.update_source(source_id, payload, user)

    @router.delete("/api/hockey/admin/mapping-data/sources/{source_id}", dependencies=admin)
    async def mapping_data_source_delete(source_id: int) -> dict[str, Any]:
        return hub.mapping_data.delete_source(source_id)

    @router.post("/api/hockey/admin/mapping-data/preview-sql", dependencies=admin)
    async def mapping_data_preview_sql(payload: MappingSqlPreviewPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        return hub.mapping_data.preview_sql(payload.sql_text, user, payload.context)

    @router.post("/api/hockey/admin/mapping-data/sources/{source_id}/preview", dependencies=admin)
    async def mapping_data_source_preview(source_id: int, payload: MappingDataContextPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        return hub.mapping_data.preview_source(source_id, user, payload.context)

    @router.post("/api/hockey/admin/mapping-data/catalog", dependencies=admin)
    async def mapping_data_catalog(payload: MappingDataContextPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        return hub.mapping_data.data_catalog(user, payload.context)

    # BUILD86: static context routes MUST be registered before /{key}.
    # Starlette matches routes in registration order; the old order treated the
    # literal word "batch" (and "resolve") as a context variable key.
    @router.post("/api/hockey/context/batch")
    async def hockey_context_batch(payload: ContextValuesPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        if len(payload.values) > 32:
            raise HTTPException(status_code=422, detail="Можно изменить не более 32 переменных за один запрос")
        results = []
        for key, value in payload.values.items():
            results.append(hub.mapping_data.set_context_value(str(key), value, user, payload.context))
        context = payload.context if isinstance(payload.context, dict) else {}
        mapping_apply = await hub.apply_mapping_for_user(
            user,
            reason="context_batch_changed",
            device_id=str(context.get("device_id") or ""),
            session_token=str(context.get("session_token") or ""),
            only_changed=True,
        )
        return {"ok": True, "items": results, "mapping_apply": mapping_apply}

    @router.post("/api/hockey/context/resolve")
    async def hockey_context_resolve(payload: MappingDataContextPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        context, variables = hub.mapping_data.resolve_context(user, payload.context)
        return {"context": context, "variables": variables}

    @router.post("/api/hockey/context/{key}")
    async def hockey_context_set(key: str, payload: ContextValuePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
        result = hub.mapping_data.set_context_value(key, payload.value, user, payload.context)
        result["mapping_apply"] = await hub.apply_mapping_for_user(user, reason=f"context_changed:{key}")
        return result

    @router.websocket("/ws/hockey-agent")
    async def agent_socket(websocket: WebSocket) -> None:
        await websocket.accept()
        device_id = ""
        try:
            raw = await asyncio.wait_for(websocket.receive_json(), timeout=12.0)
            hello = AgentHelloPayload.model_validate(raw)
            if hello.type != "hello" or hello.protocol != AGENT_PROTOCOL_VERSION:
                await websocket.close(code=4400, reason="Unsupported agent protocol")
                return
            hello_data = hello.model_dump()
            registered = await hub.register(websocket, hello_data)
            device_id = registered["device_id"]
            await websocket.send_json(
                {
                    "type": "hello.ok",
                    "protocol": AGENT_PROTOCOL_VERSION,
                    "device": registered,
                    "server_time": _utcnow().isoformat(),
                }
            )
            await hub.reconcile_device(device_id)

            while True:
                message = await websocket.receive_json()
                if not isinstance(message, dict):
                    continue
                message_type = str(message.get("type") or "")
                if message_type in {"heartbeat", "vmix.status", "command.ack", "command.batch.ack", "match.accepted", "vmix.inventory", "vmix.overlay_state"}:
                    if message_type == "vmix.overlay_state":
                        await hub.receive_overlay_state(device_id, message)
                    else:
                        await hub.receive_status(device_id, message)
                    if message_type == "vmix.inventory":
                        await hub.receive_inventory(device_id, message)
                    if message_type in {"command.ack", "command.batch.ack"}:
                        await hub.receive_command_ack(device_id, message)
                    if message_type == "heartbeat":
                        await websocket.send_json({"type": "heartbeat.ack", "server_time": _utcnow().isoformat()})
        except asyncio.TimeoutError:
            await websocket.close(code=4408, reason="Agent hello timeout")
        except (ValueError, PermissionError) as error:
            await websocket.send_json({"type": "hello.error", "detail": str(error)})
            await websocket.close(code=4403, reason="Agent registration rejected")
        except WebSocketDisconnect:
            pass
        except Exception as error:
            try:
                await websocket.send_json({"type": "server.error", "detail": str(error)[:500]})
            except Exception:
                pass
        finally:
            if device_id:
                await hub.unregister(device_id, websocket)

    return router
