"""ML admin API: settings + backfill trigger."""

from quart import Blueprint, jsonify, request

from ..extensions import get_session
from ..models import MLSettings
from ..services.ml.heads import AUTO_APPLY_THRESHOLD_MAX, AUTO_APPLY_THRESHOLD_MIN

ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")


# Crop-proposer / detector config (#134). Announced to the GPU agent in the lease
# → tunable here with no restart. weights = ultralytics name | URL | hf_repo::file
# (empty, or enabled off, skips that proposer).
_DETECTOR_FIELDS = (
    "detector_person_enabled",
    "detector_person_weights",
    "detector_person_conf",
    "detector_anatomy_enabled",
    "detector_anatomy_weights",
    "detector_anatomy_conf",
    "detector_panel_enabled",
    "detector_panel_weights",
    "detector_panel_conf",
    "detector_max_figures",
    "detector_max_components",
    "detector_max_panels",
    "detector_max_regions",
    "detector_dedupe_iou",
)

_EDITABLE = (
    "cpu_embed_enabled",
    "video_frame_interval_seconds",
    "video_max_frames",
    "head_min_positives",
    "head_auto_apply_precision",
    "head_auto_apply_enabled",
    "head_auto_apply_min_positives",
    "ccip_match_threshold",
    "ccip_auto_apply_enabled",
    "ccip_auto_apply_threshold",
    "presentation_auto_apply_enabled",
    "presentation_auto_apply_threshold",
    "presentation_conflict_threshold",
    "process_auto_apply_enabled",
    "process_auto_apply_threshold",
    "process_conflict_threshold",
    "embedder_model_name",
    "embedder_model_version",
    *_DETECTOR_FIELDS,
)


# Supported embedders for the Settings dropdown — all 1152-d so a swap is a
# drop-in (re-embed + retrain, no schema change). Server-authoritative so the UI
# never free-types a model name.
SUPPORTED_EMBEDDERS = (
    {
        "name": "google/siglip2-so400m-patch16-512",
        "version": "siglip2-so400m-patch16-512",
        "label": "SigLIP 2 · so400m · 512px (recommended)",
        "dim": 1152,
    },
    {
        "name": "google/siglip2-so400m-patch16-384",
        "version": "siglip2-so400m-patch16-384",
        "label": "SigLIP 2 · so400m · 384px (faster)",
        "dim": 1152,
    },
    {
        "name": "google/siglip-so400m-patch14-384",
        "version": "siglip-so400m-patch14-384",
        "label": "SigLIP 1 · so400m · 384px (original)",
        "dim": 1152,
    },
)


@ml_admin_bp.route("/embedder-models", methods=["GET"])
async def embedder_models():
    return jsonify({"models": list(SUPPORTED_EMBEDDERS)})


@ml_admin_bp.route("/settings", methods=["GET"])
async def get_settings():
    async with get_session() as session:
        s = await MLSettings.load(session)
    # Table-driven off _EDITABLE (which PATCH also writes) so a new settings field
    # can never be silently absent from GET — the split that historically dropped
    # fields. _EDITABLE already includes *_DETECTOR_FIELDS.
    return jsonify({f: getattr(s, f) for f in _EDITABLE})


@ml_admin_bp.route("/settings", methods=["PATCH"])
async def patch_settings():
    body = await request.get_json()
    if not isinstance(body, dict):
        return jsonify({"error": "body must be an object"}), 400
    async with get_session() as session:
        s = await MLSettings.load(session)

        # Merge the patch over current values, then validate the result as a
        # whole — the store-floor invariant couples three fields, so they
        # can't be checked one at a time.
        proposed = {f: getattr(s, f) for f in _EDITABLE}
        for field in _EDITABLE:
            if field in body:
                proposed[field] = body[field]

        err = _validate(proposed)
        if err is not None:
            return jsonify({"error": err}), 400

        for field in _EDITABLE:
            setattr(s, field, proposed[field])
        await session.commit()
    return await get_settings()


def _validate(p: dict) -> str | None:
    """Returns an error string if the proposed settings are invalid, else None."""
    # Video embedding (#747).
    if p["video_frame_interval_seconds"] <= 0:
        return "video_frame_interval_seconds must be > 0"
    if p["video_max_frames"] < 1:
        return "video_max_frames must be >= 1"
    # Head training (#114).
    if int(p["head_min_positives"]) < 1:
        return "head_min_positives must be >= 1"
    if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["head_auto_apply_precision"]) <= AUTO_APPLY_THRESHOLD_MAX):
        return f"head_auto_apply_precision must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
    if int(p["head_auto_apply_min_positives"]) < 1:
        return "head_auto_apply_min_positives must be >= 1"
    if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["ccip_match_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
        return f"ccip_match_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
    if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["ccip_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
        return f"ccip_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
    # Presentation chrome auto-hide (#141). Auto-apply runs high (hiding is
    # consequential); the conflict cut is a plain probability [0,1].
    if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["presentation_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
        return f"presentation_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
    if not (0.0 <= float(p["presentation_conflict_threshold"]) <= 1.0):
        return "presentation_conflict_threshold must be between 0 and 1"
    # Process auto-apply (#1464). wip/editor stay VISIBLE so a false apply is
    # low-harm (excludes-from-training + a review flag), but keep the same bar.
    if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["process_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
        return f"process_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
    if not (0.0 <= float(p["process_conflict_threshold"]) <= 1.0):
        return "process_conflict_threshold must be between 0 and 1"
    # Embedder model swap (#1190): both must be non-empty. Changing them means a
    # different embedding space — the operator must re-embed + retrain after.
    for key in ("embedder_model_name", "embedder_model_version"):
        if not str(p[key]).strip():
            return f"{key} must not be empty"
    # Crop proposers (#134). Weights may be empty (that proposer is just off);
    # confidences are probabilities; caps are positive counts; IoU is [0,1].
    for key in ("detector_person_conf", "detector_anatomy_conf", "detector_panel_conf"):
        if not (0.0 <= float(p[key]) <= 1.0):
            return f"{key} must be between 0 and 1"
    for key in (
        "detector_max_figures", "detector_max_components",
        "detector_max_panels", "detector_max_regions",
    ):
        if int(p[key]) < 1:
            return f"{key} must be >= 1"
    if not (0.0 <= float(p["detector_dedupe_iou"]) <= 1.0):
        return "detector_dedupe_iou must be between 0 and 1"
    return None


@ml_admin_bp.route("/backfill", methods=["POST"])
async def trigger_backfill():
    from ..tasks.ml import backfill

    r = backfill.delay()
    return jsonify({"celery_task_id": r.id}), 202
