"""Fail-closed artifact seam for a reviewed local valve detector.

This module deliberately does not choose or download an inference runtime.
Deployment code must inject a predictor factory after both the model and its
commissioning manifest have passed the checks below.  A hash-pinned model is
necessary but not sufficient: the manifest also binds preprocessing, output
shape, camera identity, calibration, evaluation evidence, and human review.
"""

from __future__ import annotations

import hashlib
import hmac
import json
import math
import os
import stat
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Iterable, Mapping, Sequence, Tuple

from .cameras import CameraFrame, CameraRole
from .valves import ModelValveDetector, ValveDetection

MANIFEST_SCHEMA = "labcobot.reviewed-valve-detector.v1"
OUTPUT_SCHEMA = "labcobot.valve-detections.v1"
MODEL_FORMAT = "onnx-opencv-dnn-v1"
MAXIMUM_MANIFEST_BYTES = 64 * 1024
MAXIMUM_MODEL_BYTES = 512 * 1024 * 1024
MINIMUM_EVALUATION_SAMPLES = 100
MINIMUM_EVALUATION_PRECISION = 0.90
MINIMUM_EVALUATION_RECALL = 0.90


class ReviewedValveModelError(RuntimeError):
    """Raised when a local detector is not safe to load or use."""


@dataclass(frozen=True)
class ReviewedValveModelArtifact:
    model_path: Path
    model_sha256: str
    manifest_sha256: str
    tool_camera_source_id: str
    camera_width: int
    camera_height: int
    input_width: int
    input_height: int
    padding_value: int
    candidate_count: int
    confidence_threshold: float
    nms_iou_threshold: float
    maximum_detections: int
    maximum_inference_seconds: float
    allowed_labels: Tuple[str, ...]
    manifest: Mapping[str, Any]


Predictor = Callable[[Any], Iterable[Mapping[str, Any]]]
PredictorFactory = Callable[[Path, ReviewedValveModelArtifact], Predictor]


def load_reviewed_valve_model(
    *,
    model_path: str,
    expected_model_sha256: str,
    manifest_path: str,
    expected_manifest_sha256: str,
    tool_camera_source_id: str,
    camera_width: int,
    camera_height: int,
    eye_in_hand_calibration_sha256: str,
    confidence_threshold: float,
    required_owner_uid: int = 0,
) -> ReviewedValveModelArtifact:
    """Load one exact reviewed detector bundle without executing model code."""

    model_digest = _sha256(expected_model_sha256, "reviewed valve model")
    manifest_digest = _sha256(
        expected_manifest_sha256,
        "reviewed valve model manifest",
    )
    model = Path(model_path)
    manifest_file = Path(manifest_path)
    model_bytes = _read_regular_file(
        model,
        maximum_bytes=MAXIMUM_MODEL_BYTES,
        name="reviewed valve model",
        required_owner_uid=required_owner_uid,
    )
    if not hmac.compare_digest(hashlib.sha256(model_bytes).hexdigest(), model_digest):
        raise ReviewedValveModelError(
            "Reviewed valve model SHA-256 does not match the configured value"
        )
    manifest_bytes = _read_regular_file(
        manifest_file,
        maximum_bytes=MAXIMUM_MANIFEST_BYTES,
        name="reviewed valve model manifest",
        required_owner_uid=required_owner_uid,
    )
    if not hmac.compare_digest(
        hashlib.sha256(manifest_bytes).hexdigest(),
        manifest_digest,
    ):
        raise ReviewedValveModelError(
            "Reviewed valve model manifest SHA-256 does not match the configured value"
        )
    try:
        raw = json.loads(
            manifest_bytes.decode("utf-8"),
            object_pairs_hook=_unique_json_object,
        )
    except (UnicodeError, json.JSONDecodeError) as exc:
        raise ReviewedValveModelError(
            "Reviewed valve model manifest is not valid JSON"
        ) from exc
    manifest = _mapping(raw, "reviewed valve model manifest")
    if set(manifest) != {
        "schema",
        "model",
        "input",
        "output",
        "bindings",
        "evaluation",
        "review",
    }:
        raise ReviewedValveModelError(
            "Reviewed valve model manifest fields are incomplete or unsupported"
        )
    if manifest.get("schema") != MANIFEST_SCHEMA:
        raise ReviewedValveModelError("Unsupported reviewed valve model schema")

    model_contract = _exact_mapping(
        manifest.get("model"),
        "model contract",
        ("format", "sha256"),
    )
    if model_contract.get("format") != MODEL_FORMAT:
        raise ReviewedValveModelError("Unsupported reviewed valve model format")
    if not hmac.compare_digest(
        _sha256(model_contract.get("sha256"), "manifest model"),
        model_digest,
    ):
        raise ReviewedValveModelError(
            "Reviewed valve model manifest binds a different model"
        )

    input_contract = _exact_mapping(
        manifest.get("input"),
        "model input contract",
        (
            "width",
            "height",
            "layout",
            "color_order",
            "resize",
            "padding_value",
            "scale",
            "mean",
        ),
    )
    input_width = _positive_integer(input_contract.get("width"), "model input width")
    input_height = _positive_integer(input_contract.get("height"), "model input height")
    if input_width > 4096 or input_height > 4096:
        raise ReviewedValveModelError("Reviewed valve model input is too large")
    if (
        input_contract.get("layout") != "nchw"
        or input_contract.get("color_order") != "rgb"
        or input_contract.get("resize") != "letterbox"
    ):
        raise ReviewedValveModelError(
            "Reviewed valve model preprocessing is unsupported"
        )
    padding_value = input_contract.get("padding_value")
    if (
        isinstance(padding_value, bool)
        or not isinstance(padding_value, int)
        or not 0 <= padding_value <= 255
    ):
        raise ReviewedValveModelError(
            "Reviewed valve model padding value must be an 8-bit integer"
        )
    scale = _finite_number(input_contract.get("scale"), "model input scale")
    if not math.isclose(scale, 1.0 / 255.0, rel_tol=0.0, abs_tol=1e-12):
        raise ReviewedValveModelError("Reviewed valve model input scale is unsupported")
    mean = _finite_vector(input_contract.get("mean"), 3, "model input mean")
    if mean != (0.0, 0.0, 0.0):
        raise ReviewedValveModelError("Reviewed valve model input mean is unsupported")

    output = _exact_mapping(
        manifest.get("output"),
        "model output contract",
        (
            "schema",
            "labels",
            "tensor_shape",
            "bbox_format",
            "coordinate_space",
            "class_id",
            "confidence_threshold",
            "nms_iou_threshold",
            "maximum_detections",
            "maximum_inference_seconds",
        ),
    )
    if (
        output.get("schema") != OUTPUT_SCHEMA
        or output.get("bbox_format") != "xyxy_pixels"
        or output.get("coordinate_space") != "letterboxed_input_pixels"
    ):
        raise ReviewedValveModelError("Reviewed valve model output is unsupported")
    labels = output.get("labels")
    if labels != ["valve"]:
        raise ReviewedValveModelError(
            "Reviewed valve model must expose only the valve label"
        )
    if output.get("class_id") != 0:
        raise ReviewedValveModelError(
            "Reviewed valve model must bind the valve label to class ID zero"
        )
    tensor_shape = output.get("tensor_shape")
    if (
        not isinstance(tensor_shape, list)
        or len(tensor_shape) != 3
        or tensor_shape[0] != 1
        or tensor_shape[2] != 6
    ):
        raise ReviewedValveModelError(
            "Reviewed valve model output tensor must have shape [1, N, 6]"
        )
    candidate_count = _positive_integer(
        tensor_shape[1],
        "reviewed valve model candidate count",
    )
    if candidate_count > 4096:
        raise ReviewedValveModelError(
            "Reviewed valve model candidate count exceeds 4096"
        )
    reviewed_confidence = _probability(
        output.get("confidence_threshold"),
        "model confidence threshold",
    )
    if not math.isclose(
        reviewed_confidence,
        float(confidence_threshold),
        rel_tol=0.0,
        abs_tol=1e-12,
    ):
        raise ReviewedValveModelError(
            "Reviewed valve model confidence threshold does not match runtime"
        )
    nms_iou = _probability(
        output.get("nms_iou_threshold"),
        "model NMS IoU threshold",
    )
    maximum_detections = _positive_integer(
        output.get("maximum_detections"),
        "maximum model detections",
    )
    if maximum_detections > 64:
        raise ReviewedValveModelError("Maximum model detections exceeds 64")
    maximum_inference_seconds = _finite_number(
        output.get("maximum_inference_seconds"),
        "maximum inference time",
    )
    if not 0.0 < maximum_inference_seconds <= 0.25:
        raise ReviewedValveModelError(
            "Maximum inference time must be positive and no more than 0.25 seconds"
        )

    bindings = _exact_mapping(
        manifest.get("bindings"),
        "model bindings",
        (
            "tool_camera_source_id",
            "camera_width",
            "camera_height",
            "eye_in_hand_calibration_sha256",
        ),
    )
    if bindings.get("tool_camera_source_id") != str(tool_camera_source_id):
        raise ReviewedValveModelError(
            "Reviewed valve model is bound to another tool camera"
        )
    if bindings.get("camera_width") != int(camera_width) or bindings.get(
        "camera_height"
    ) != int(camera_height):
        raise ReviewedValveModelError(
            "Reviewed valve model camera resolution does not match runtime"
        )
    if not hmac.compare_digest(
        _sha256(
            bindings.get("eye_in_hand_calibration_sha256"),
            "manifest eye-in-hand calibration",
        ),
        _sha256(
            eye_in_hand_calibration_sha256,
            "runtime eye-in-hand calibration",
        ),
    ):
        raise ReviewedValveModelError(
            "Reviewed valve model is bound to another eye-in-hand calibration"
        )

    evaluation = _exact_mapping(
        manifest.get("evaluation"),
        "model evaluation",
        ("dataset_sha256", "sample_count", "precision", "recall"),
    )
    _sha256(evaluation.get("dataset_sha256"), "model evaluation dataset")
    sample_count = _positive_integer(
        evaluation.get("sample_count"),
        "model evaluation sample count",
    )
    precision = _probability(evaluation.get("precision"), "model evaluation precision")
    recall = _probability(evaluation.get("recall"), "model evaluation recall")
    if sample_count < MINIMUM_EVALUATION_SAMPLES:
        raise ReviewedValveModelError(
            "Reviewed valve model evaluation has fewer than 100 samples"
        )
    if precision < MINIMUM_EVALUATION_PRECISION or recall < MINIMUM_EVALUATION_RECALL:
        raise ReviewedValveModelError(
            "Reviewed valve model evaluation is below the live-motion threshold"
        )

    review = _exact_mapping(
        manifest.get("review"),
        "model review",
        ("accepted_for_live_motion", "reviewed_by", "reviewed_at"),
    )
    if review.get("accepted_for_live_motion") is not True:
        raise ReviewedValveModelError(
            "Reviewed valve model is not accepted for live motion"
        )
    for field in ("reviewed_by", "reviewed_at"):
        value = review.get(field)
        if not isinstance(value, str) or not value.strip() or len(value) > 160:
            raise ReviewedValveModelError("Model review identity is invalid")

    return ReviewedValveModelArtifact(
        model_path=model,
        model_sha256=model_digest,
        manifest_sha256=manifest_digest,
        tool_camera_source_id=str(tool_camera_source_id),
        camera_width=int(camera_width),
        camera_height=int(camera_height),
        input_width=input_width,
        input_height=input_height,
        padding_value=padding_value,
        candidate_count=candidate_count,
        confidence_threshold=reviewed_confidence,
        nms_iou_threshold=nms_iou,
        maximum_detections=maximum_detections,
        maximum_inference_seconds=maximum_inference_seconds,
        allowed_labels=("valve",),
        manifest=dict(manifest),
    )


def build_opencv_dnn_valve_predictor(
    model_path: Path,
    artifact: ReviewedValveModelArtifact,
) -> Predictor:
    """Build the sole supported local ONNX inference implementation.

    The reviewed manifest deliberately describes one fixed output tensor:
    ``[1, N, 6]`` with ``x1, y1, x2, y2, confidence, class_id`` rows in
    letterboxed input pixels.  Alternate YOLO layouts, executable Python model
    packages, dynamic output shapes, and implicit class mappings are rejected.
    """

    try:
        import cv2
        import numpy as np
    except ImportError as exc:
        raise ReviewedValveModelError(
            "Reviewed local valve inference requires OpenCV and NumPy"
        ) from exc
    try:
        network = cv2.dnn.readNetFromONNX(str(model_path))
        network.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV)
        network.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)
        output_names = tuple(network.getUnconnectedOutLayersNames())
    except Exception as exc:
        raise ReviewedValveModelError(
            "Reviewed local valve ONNX model could not be loaded"
        ) from exc
    if (
        len(output_names) != 1
        or not isinstance(output_names[0], str)
        or not output_names[0]
    ):
        raise ReviewedValveModelError(
            "Reviewed valve ONNX model must expose exactly one output tensor"
        )
    lock = threading.Lock()

    def predict(image: Any) -> Iterable[Mapping[str, Any]]:
        if (
            not isinstance(image, np.ndarray)
            or image.dtype != np.uint8
            or image.ndim != 3
            or image.shape
            != (
                artifact.camera_height,
                artifact.camera_width,
                3,
            )
        ):
            raise ReviewedValveModelError(
                "Reviewed local valve inference requires one uint8 BGR frame"
            )
        scale = min(
            artifact.input_width / artifact.camera_width,
            artifact.input_height / artifact.camera_height,
        )
        resized_width = max(1, int(round(artifact.camera_width * scale)))
        resized_height = max(1, int(round(artifact.camera_height * scale)))
        if (
            resized_width > artifact.input_width
            or resized_height > artifact.input_height
        ):
            raise ReviewedValveModelError("Reviewed valve letterbox is invalid")
        pad_x = (artifact.input_width - resized_width) // 2
        pad_y = (artifact.input_height - resized_height) // 2
        try:
            resized = cv2.resize(
                image,
                (resized_width, resized_height),
                interpolation=cv2.INTER_LINEAR,
            )
            padded = np.full(
                (artifact.input_height, artifact.input_width, 3),
                artifact.padding_value,
                dtype=np.uint8,
            )
            padded[
                pad_y : pad_y + resized_height,
                pad_x : pad_x + resized_width,
            ] = resized
            blob = cv2.dnn.blobFromImage(
                padded,
                scalefactor=1.0 / 255.0,
                size=(artifact.input_width, artifact.input_height),
                mean=(0.0, 0.0, 0.0),
                swapRB=True,
                crop=False,
            )
            if blob.shape != (
                1,
                3,
                artifact.input_height,
                artifact.input_width,
            ):
                raise ReviewedValveModelError(
                    "Reviewed valve input tensor has an unexpected shape"
                )
            with lock:
                network.setInput(blob)
                raw_output = network.forward()
        except ReviewedValveModelError:
            raise
        except Exception as exc:
            raise ReviewedValveModelError(
                "Reviewed local valve ONNX inference failed"
            ) from exc
        if not isinstance(raw_output, np.ndarray):
            raise ReviewedValveModelError(
                "Reviewed valve ONNX model must expose exactly one output tensor"
            )
        output = np.asarray(raw_output)
        expected_shape = (1, artifact.candidate_count, 6)
        if output.shape != expected_shape:
            raise ReviewedValveModelError(
                "Reviewed valve output tensor has shape {}; expected {}".format(
                    tuple(output.shape), expected_shape
                )
            )
        if output.dtype.kind not in {"f", "i", "u"}:
            raise ReviewedValveModelError("Reviewed valve output tensor is not numeric")
        candidates = []
        for index, raw_row in enumerate(output[0]):
            try:
                values = tuple(float(value) for value in raw_row)
            except (TypeError, ValueError, OverflowError) as exc:
                raise ReviewedValveModelError(
                    "Reviewed valve output row is invalid"
                ) from exc
            if len(values) != 6 or not all(math.isfinite(value) for value in values):
                raise ReviewedValveModelError(
                    "Reviewed valve output row contains non-finite values"
                )
            x_min, y_min, x_max, y_max, confidence, class_value = values
            if not 0.0 <= confidence <= 1.0:
                raise ReviewedValveModelError(
                    "Reviewed valve output confidence is invalid"
                )
            if class_value != 0.0:
                raise ReviewedValveModelError(
                    "Reviewed valve output contains an unreviewed class ID"
                )
            if not (
                0.0 <= x_min < x_max <= artifact.input_width
                and 0.0 <= y_min < y_max <= artifact.input_height
            ):
                raise ReviewedValveModelError(
                    "Reviewed valve output bounding box is invalid"
                )
            if confidence < artifact.confidence_threshold:
                continue
            mapped = (
                max(0.0, min(float(artifact.camera_width), (x_min - pad_x) / scale)),
                max(0.0, min(float(artifact.camera_height), (y_min - pad_y) / scale)),
                max(0.0, min(float(artifact.camera_width), (x_max - pad_x) / scale)),
                max(0.0, min(float(artifact.camera_height), (y_max - pad_y) / scale)),
            )
            if not mapped[0] < mapped[2] or not mapped[1] < mapped[3]:
                raise ReviewedValveModelError(
                    "Reviewed valve output lies outside the camera image"
                )
            candidates.append((confidence, index, mapped))
        kept = _non_maximum_suppression(
            candidates,
            iou_threshold=artifact.nms_iou_threshold,
            maximum=artifact.maximum_detections,
        )
        return [
            {"label": "valve", "confidence": confidence, "bbox": bbox}
            for confidence, _index, bbox in kept
        ]

    return predict


def _non_maximum_suppression(
    candidates: Sequence[Tuple[float, int, Tuple[float, float, float, float]]],
    *,
    iou_threshold: float,
    maximum: int,
) -> Sequence[Tuple[float, int, Tuple[float, float, float, float]]]:
    ordered = sorted(candidates, key=lambda item: (-item[0], item[1]))
    kept = []
    for candidate in ordered:
        if all(
            _intersection_over_union(candidate[2], previous[2]) <= iou_threshold
            for previous in kept
        ):
            kept.append(candidate)
            if len(kept) >= maximum:
                break
    return tuple(kept)


def _intersection_over_union(
    first: Tuple[float, float, float, float],
    second: Tuple[float, float, float, float],
) -> float:
    intersection_width = max(0.0, min(first[2], second[2]) - max(first[0], second[0]))
    intersection_height = max(0.0, min(first[3], second[3]) - max(first[1], second[1]))
    intersection = intersection_width * intersection_height
    first_area = (first[2] - first[0]) * (first[3] - first[1])
    second_area = (second[2] - second[0]) * (second[3] - second[1])
    union = first_area + second_area - intersection
    return 0.0 if union <= 0.0 else intersection / union


class ReviewedLocalValveDetector:
    """Bound a trusted predictor factory to one verified detector artifact."""

    confirmation_source = "local_reviewed_model"

    def __init__(
        self,
        artifact: ReviewedValveModelArtifact,
        predictor_factory: PredictorFactory,
        *,
        monotonic: Callable[[], float] = time.monotonic,
    ) -> None:
        if not callable(predictor_factory):
            raise ReviewedValveModelError(
                "A reviewed local valve predictor factory is not installed"
            )
        predictor = predictor_factory(artifact.model_path, artifact)
        if not callable(predictor):
            raise ReviewedValveModelError(
                "Reviewed local valve predictor factory returned no predictor"
            )
        self.artifact = artifact
        self.predictor = predictor
        self.monotonic = monotonic

    def detect(self, frame: CameraFrame) -> Sequence[ValveDetection]:
        if (
            frame.role is not CameraRole.TOOL
            or frame.source_id != self.artifact.tool_camera_source_id
        ):
            raise ReviewedValveModelError(
                "Reviewed local valve detector received an unbound camera frame"
            )
        shape = getattr(frame.image, "shape", None)
        if (
            not isinstance(shape, tuple)
            or len(shape) < 2
            or int(shape[0]) != self.artifact.camera_height
            or int(shape[1]) != self.artifact.camera_width
        ):
            raise ReviewedValveModelError(
                "Reviewed local valve detector received an unexpected image size"
            )
        started = float(self.monotonic())
        rows = self.predictor(frame.image)
        elapsed = float(self.monotonic()) - started
        if elapsed < 0.0 or elapsed > self.artifact.maximum_inference_seconds:
            raise ReviewedValveModelError(
                "Reviewed local valve inference exceeded its timing budget"
            )
        if not isinstance(rows, (list, tuple)):
            raise ReviewedValveModelError(
                "Reviewed local valve predictor must return a bounded sequence"
            )
        if len(rows) > self.artifact.maximum_detections:
            raise ReviewedValveModelError(
                "Reviewed local valve predictor returned too many detections"
            )
        if any(
            not isinstance(row, Mapping)
            or "label" not in row
            or "confidence" not in row
            or "bbox" not in row
            for row in rows
        ):
            raise ReviewedValveModelError(
                "Reviewed local valve predictor returned an invalid detection"
            )
        detections = tuple(
            ModelValveDetector(
                lambda _image: rows,
                confidence_threshold=self.artifact.confidence_threshold,
                allowed_labels=self.artifact.allowed_labels,
            ).detect(frame)
        )
        for detection in detections:
            box = detection.bbox
            if not (
                0.0 <= box.x_min < box.x_max <= self.artifact.camera_width
                and 0.0 <= box.y_min < box.y_max <= self.artifact.camera_height
            ):
                raise ReviewedValveModelError(
                    "Reviewed local valve prediction lies outside the image"
                )
        return detections


def _read_regular_file(
    path: Path,
    *,
    maximum_bytes: int,
    name: str,
    required_owner_uid: int,
) -> bytes:
    flags = os.O_RDONLY
    if hasattr(os, "O_NOFOLLOW"):
        flags |= os.O_NOFOLLOW
    try:
        descriptor = os.open(str(path), flags)
    except OSError as exc:
        raise ReviewedValveModelError("{} is unavailable".format(name)) from exc
    try:
        info = os.fstat(descriptor)
        identity = _stable_file_identity(info)
        if (
            not stat.S_ISREG(info.st_mode)
            or info.st_size < 1
            or info.st_size > maximum_bytes
        ):
            raise ReviewedValveModelError("{} has an invalid file shape".format(name))
        if info.st_uid != int(required_owner_uid):
            raise ReviewedValveModelError(
                "{} is not owned by the required deployment account".format(name)
            )
        if info.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
            raise ReviewedValveModelError(
                "{} must not be group- or world-writable".format(name)
            )
        chunks = []
        remaining = info.st_size
        while remaining:
            chunk = os.read(descriptor, min(remaining, 1024 * 1024))
            if not chunk:
                raise ReviewedValveModelError("{} changed while reading".format(name))
            chunks.append(chunk)
            remaining -= len(chunk)
        if _stable_file_identity(os.fstat(descriptor)) != identity:
            raise ReviewedValveModelError("{} changed while reading".format(name))
        return b"".join(chunks)
    finally:
        os.close(descriptor)


def _stable_file_identity(info: os.stat_result) -> Tuple[int, ...]:
    return (
        int(info.st_dev),
        int(info.st_ino),
        int(info.st_mode),
        int(info.st_nlink),
        int(info.st_size),
        int(info.st_mtime_ns),
        int(info.st_ctime_ns),
    )


def _unique_json_object(pairs: Sequence[Tuple[str, Any]]) -> Mapping[str, Any]:
    result = {}
    for key, value in pairs:
        if key in result:
            raise ReviewedValveModelError(
                "Reviewed valve model manifest contains a duplicate field"
            )
        result[key] = value
    return result


def _mapping(value: Any, name: str) -> Mapping[str, Any]:
    if not isinstance(value, Mapping):
        raise ReviewedValveModelError("{} must be an object".format(name))
    return value


def _exact_mapping(value: Any, name: str, fields: Sequence[str]) -> Mapping[str, Any]:
    result = _mapping(value, name)
    if set(result) != set(fields):
        raise ReviewedValveModelError(
            "{} fields are incomplete or unsupported".format(name)
        )
    return result


def _sha256(value: Any, name: str) -> str:
    cleaned = str(value or "").strip().lower()
    if len(cleaned) != 64 or any(
        character not in "0123456789abcdef" for character in cleaned
    ):
        raise ReviewedValveModelError("{} SHA-256 is invalid".format(name))
    return cleaned


def _positive_integer(value: Any, name: str) -> int:
    if isinstance(value, bool) or not isinstance(value, int) or value < 1:
        raise ReviewedValveModelError("{} must be a positive integer".format(name))
    return value


def _finite_number(value: Any, name: str) -> float:
    if isinstance(value, bool):
        raise ReviewedValveModelError("{} must be finite".format(name))
    try:
        number = float(value)
    except (TypeError, ValueError) as exc:
        raise ReviewedValveModelError("{} must be finite".format(name)) from exc
    if not math.isfinite(number):
        raise ReviewedValveModelError("{} must be finite".format(name))
    return number


def _finite_vector(value: Any, length: int, name: str) -> Tuple[float, ...]:
    if not isinstance(value, (list, tuple)) or len(value) != length:
        raise ReviewedValveModelError("{} has an invalid shape".format(name))
    return tuple(_finite_number(item, name) for item in value)


def _probability(value: Any, name: str) -> float:
    number = _finite_number(value, name)
    if not 0.0 <= number <= 1.0:
        raise ReviewedValveModelError("{} must be between zero and one".format(name))
    return number


__all__ = [
    "MANIFEST_SCHEMA",
    "MODEL_FORMAT",
    "OUTPUT_SCHEMA",
    "PredictorFactory",
    "build_opencv_dnn_valve_predictor",
    "ReviewedLocalValveDetector",
    "ReviewedValveModelArtifact",
    "ReviewedValveModelError",
    "load_reviewed_valve_model",
]
