"""Compile human-authored BOS form maps into OMRChecker templates.

The mapper definition is the editable source of truth. OMRChecker template.json is
compiled output. Logical field semantics are explicit so visual layout is never
used to guess whether bubbles belong to one field or several.
"""
from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Any

FORM_ID_RE = re.compile(r"^BF\d{6}$")
FIELD_TYPES = {"required_single", "optional_single", "checkbox", "digit", "control"}


class FormMapError(ValueError):
    pass


def _point(value: Any, label: str) -> tuple[float, float]:
    if not isinstance(value, list) or len(value) != 2:
        raise FormMapError(f"{label} must be [x, y]")
    return float(value[0]), float(value[1])


def validate_definition(definition: dict[str, Any]) -> None:
    form_id = str(definition.get("form_id", ""))
    if not FORM_ID_RE.fullmatch(form_id):
        raise FormMapError("form_id must match BF######")
    page = definition.get("page") or {}
    if not page.get("width") or not page.get("height"):
        raise FormMapError("page width and height are required")
    bubble = definition.get("bubble") or {}
    if not bubble.get("width") or not bubble.get("height"):
        raise FormMapError("bubble width and height are required")
    seen: set[str] = set()
    for field in definition.get("fields", []):
        key = str(field.get("key", "")).strip()
        if not key or key in seen:
            raise FormMapError(f"field key missing or duplicated: {key!r}")
        seen.add(key)
        field_type = field.get("type")
        if field_type not in FIELD_TYPES:
            raise FormMapError(f"{key}: unsupported field type {field_type!r}")
        bubbles = field.get("bubbles") or []
        if not bubbles:
            raise FormMapError(f"{key}: at least one bubble is required")
        values: set[str] = set()
        for index, item in enumerate(bubbles, 1):
            value = str(item.get("value", ""))
            if value in values:
                raise FormMapError(f"{key}: duplicate bubble value {value!r}")
            values.add(value)
            _point(item.get("center"), f"{key} bubble {index} center")
        if field_type == "checkbox" and len(bubbles) != 1:
            raise FormMapError(f"{key}: checkbox requires exactly one bubble")
        if field_type == "digit" and values != set("0123456789"):
            raise FormMapError(f"{key}: digit field must map values 0-9 exactly")
        if field_type == "control" and field.get("expected") is None:
            raise FormMapError(f"{key}: control field requires expected value")


def compile_omr_template(definition: dict[str, Any]) -> dict[str, Any]:
    """Compile explicit bubble centers to OMRChecker field blocks.

    Each logical BOS field becomes one OMRChecker field block. This deliberately
    avoids inferring fields from grids. A 5-cone 5/10 grid is therefore authored
    as five optional_single fields, not one ten-choice field.
    """
    validate_definition(definition)
    bw = float(definition["bubble"]["width"])
    bh = float(definition["bubble"]["height"])
    blocks: dict[str, Any] = {}
    for field in definition["fields"]:
        bubbles = field["bubbles"]
        centers = [_point(item["center"], field["key"]) for item in bubbles]
        values = [str(item["value"]) for item in bubbles]
        # Explicit centers are preserved in BOS metadata. OMRChecker requires a
        # regular block, so mapper UI/compiler verifies collinearity/equal pitch.
        if len(centers) == 1:
            direction, gap = "horizontal", 0.0
        else:
            dx = centers[1][0] - centers[0][0]
            dy = centers[1][1] - centers[0][1]
            direction = "horizontal" if abs(dx) >= abs(dy) else "vertical"
            gap = dx if direction == "horizontal" else dy
            tolerance = 0.75
            for i in range(1, len(centers)):
                expected = centers[0][0] + (gap * i if direction == "horizontal" else 0)
                expected_y = centers[0][1] + (gap * i if direction == "vertical" else 0)
                if abs(centers[i][0] - expected) > tolerance or abs(centers[i][1] - expected_y) > tolerance:
                    raise FormMapError(f"{field['key']}: bubbles are not a regular row/column; split into separate fields")
        blocks[field["key"]] = {
            "bubbleValues": values,
            "direction": direction,
            "origin": [centers[0][0] - bw / 2, centers[0][1] - bh / 2],
            "fieldLabels": [field["key"]],
            "bubblesGap": gap,
            "labelsGap": 0,
        }
    return {
        "pageDimensions": [definition["page"]["width"], definition["page"]["height"]],
        "bubbleDimensions": [bw, bh],
        "fieldBlocks": blocks,
        "emptyValue": "",
        "preProcessors": definition.get("preprocessors", []),
        "bos": {
            "form_id": definition["form_id"],
            "field_semantics": {f["key"]: {k: v for k, v in f.items() if k not in {"bubbles"}} for f in definition["fields"]},
        },
    }
