import json
import os
import unittest
from unittest.mock import patch

import numpy as np
from pydantic import ValidationError
from ruamel.yaml.constructor import DuplicateKeyError

from frigate.config import BirdseyeModeEnum, FrigateConfig
from frigate.const import MODEL_CACHE_DIR
from frigate.detectors import DetectorTypeEnum
from frigate.util.builtin import deep_merge


class TestConfig(unittest.TestCase):
    def setUp(self):
        self.minimal = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        self.plus_model_info = {
            "id": "e63b7345cc83a84ed79dedfc99c16616",
            "name": "SSDLite Mobiledet",
            "description": "Fine tuned model",
            "trainDate": "2023-04-28T23:22:01.262Z",
            "type": "ssd",
            "supportedDetectors": ["cpu", "edgetpu"],
            "width": 320,
            "height": 320,
            "inputShape": "nhwc",
            "pixelFormat": "rgb",
            "labelMap": {
                "0": "amazon",
                "1": "car",
                "2": "cat",
                "3": "deer",
                "4": "dog",
                "5": "face",
                "6": "fedex",
                "7": "license_plate",
                "8": "package",
                "9": "person",
                "10": "ups",
            },
        }

        if not os.path.exists(MODEL_CACHE_DIR) and not os.path.islink(MODEL_CACHE_DIR):
            os.makedirs(MODEL_CACHE_DIR)

    def test_config_class(self):
        frigate_config = FrigateConfig(**self.minimal)
        assert "cpu" in frigate_config.detectors.keys()
        assert frigate_config.detectors["cpu"].type == DetectorTypeEnum.cpu
        assert frigate_config.detectors["cpu"].model.width == 320

    @patch("frigate.detectors.detector_config.load_labels")
    def test_detector_custom_model_path(self, mock_labels):
        mock_labels.return_value = {}
        config = {
            "detectors": {
                "cpu": {
                    "type": "cpu",
                    "model_path": "/cpu_model.tflite",
                },
                "edgetpu": {
                    "type": "edgetpu",
                    "model_path": "/edgetpu_model.tflite",
                },
                "openvino": {
                    "type": "openvino",
                },
            },
            # needs to be a file that will exist, doesn't matter what
            "models": {"default": {"path": "/etc/hosts", "width": 512}},
        }

        frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))

        assert "cpu" in frigate_config.detectors.keys()
        assert "edgetpu" in frigate_config.detectors.keys()
        assert "openvino" in frigate_config.detectors.keys()

        assert frigate_config.detectors["cpu"].type == DetectorTypeEnum.cpu
        assert frigate_config.detectors["edgetpu"].type == DetectorTypeEnum.edgetpu
        assert frigate_config.detectors["openvino"].type == DetectorTypeEnum.openvino

        assert frigate_config.detectors["cpu"].num_threads == 3
        assert frigate_config.detectors["edgetpu"].device is None
        assert frigate_config.detectors["openvino"].device is None

        assert frigate_config.models["default"].path == "/etc/hosts"
        assert frigate_config.detectors["cpu"].model.path == "/cpu_model.tflite"
        assert frigate_config.detectors["edgetpu"].model.path == "/edgetpu_model.tflite"
        assert frigate_config.detectors["openvino"].model.path == "/etc/hosts"

    def test_invalid_mqtt_config(self):
        config = {
            "mqtt": {"host": "mqtt", "user": "test"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }
        self.assertRaises(ValidationError, lambda: FrigateConfig(**config))

    def test_inherit_tracked_objects(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "objects": {"track": ["person", "dog"]},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert "dog" in frigate_config.cameras["back"].objects.track

    def test_deep_merge_override_replaces_list_values(self):
        base = {"objects": {"track": ["person", "face"]}}
        update = {"objects": {"track": ["person"]}}

        merged = deep_merge(base, update, override=True)

        assert merged["objects"]["track"] == ["person"]

    def test_deep_merge_merge_lists_still_appends(self):
        base = {"track": ["person"]}
        update = {"track": ["face"]}

        merged = deep_merge(base, update, override=True, merge_lists=True)

        assert merged["track"] == ["person", "face"]

    def test_override_birdseye(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "birdseye": {"enabled": True, "mode": "continuous"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "birdseye": {"enabled": False, "mode": "motion"},
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert not frigate_config.cameras["back"].birdseye.enabled
        assert frigate_config.cameras["back"].birdseye.mode is BirdseyeModeEnum.motion

    def test_override_birdseye_non_inheritable(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "birdseye": {"enabled": True, "mode": "continuous", "height": 1920},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["back"].birdseye.enabled

    def test_inherit_birdseye(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "birdseye": {"enabled": True, "mode": "continuous"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["back"].birdseye.enabled
        assert (
            frigate_config.cameras["back"].birdseye.mode is BirdseyeModeEnum.continuous
        )

    def test_override_tracked_objects(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "objects": {"track": ["person", "dog"]},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "objects": {"track": ["cat"]},
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert "cat" in frigate_config.cameras["back"].objects.track

    def test_default_object_filters(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "objects": {"track": ["person", "dog"]},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert "dog" in frigate_config.cameras["back"].objects.filters

    def test_default_audio_filters(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "audio": {"listen": ["speech", "yell"]},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert set(frigate_config.cameras["back"].audio.filters.keys()) == {
            "speech",
            "yell",
        }

    def test_override_audio_filters(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "audio": {
                        "listen": ["speech", "yell"],
                        "filters": {"speech": {"threshold": 0.9}},
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert "speech" in frigate_config.cameras["back"].audio.filters
        assert frigate_config.cameras["back"].audio.filters["speech"].threshold == 0.9
        assert "yell" in frigate_config.cameras["back"].audio.filters
        assert "babbling" not in frigate_config.cameras["back"].audio.filters

    def test_inherit_object_filters(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "objects": {
                "track": ["person", "dog"],
                "filters": {"dog": {"threshold": 0.7}},
            },
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert "dog" in frigate_config.cameras["back"].objects.filters
        assert frigate_config.cameras["back"].objects.filters["dog"].threshold == 0.7

    def test_override_object_filters(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "objects": {
                        "track": ["person", "dog"],
                        "filters": {"dog": {"threshold": 0.7}},
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert "dog" in frigate_config.cameras["back"].objects.filters
        assert frigate_config.cameras["back"].objects.filters["dog"].threshold == 0.7

    def test_unsupported_tracked_object_pruned_from_track_and_filters(self):
        # "unicorn" is not in the model labelmap, so it must be removed from the
        # tracked objects AND from the object filters, otherwise a stale filter
        # entry lingers in the parsed config.
        config = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "objects": {
                        "track": ["person", "unicorn"],
                        "filters": {
                            "person": {"threshold": 0.7},
                            "unicorn": {"threshold": 0.7},
                        },
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        objects = frigate_config.cameras["back"].objects
        assert "unicorn" not in objects.track
        assert "unicorn" not in objects.filters
        # supported entries are left untouched
        assert "person" in objects.track
        assert "person" in objects.filters

    def test_global_object_mask(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "objects": {"track": ["person", "dog"]},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "objects": {
                        "mask": {
                            "global_mask_1": {
                                "friendly_name": "Global Mask 1",
                                "enabled": True,
                                "coordinates": "0,0,1,1,0,1",
                            }
                        },
                        "filters": {
                            "dog": {
                                "mask": {
                                    "dog_mask_1": {
                                        "friendly_name": "Dog Mask 1",
                                        "enabled": True,
                                        "coordinates": "1,1,1,1,1,1",
                                    }
                                }
                            }
                        },
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        back_camera = frigate_config.cameras["back"]
        assert "dog" in back_camera.objects.filters
        # dog filter has its own mask + global mask merged
        assert len(back_camera.objects.filters["dog"].mask) == 2
        # person filter only has the global mask
        assert len(back_camera.objects.filters["person"].mask) == 1

    def test_motion_mask_relative_matches_explicit(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "record": {"alerts": {"retain": {"days": 20}}},
            "cameras": {
                "explicit": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 400,
                        "width": 800,
                        "fps": 5,
                    },
                    "motion": {
                        "mask": {
                            "explicit_mask": {
                                "friendly_name": "Explicit Mask",
                                "enabled": True,
                                "coordinates": "0,0,200,100,600,300,800,400",
                            }
                        }
                    },
                },
                "relative": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 400,
                        "width": 800,
                        "fps": 5,
                    },
                    "motion": {
                        "mask": {
                            "relative_mask": {
                                "friendly_name": "Relative Mask",
                                "enabled": True,
                                "coordinates": "0.0,0.0,0.25,0.25,0.75,0.75,1.0,1.0",
                            }
                        }
                    },
                },
            },
        }

        frigate_config = FrigateConfig(**config)
        assert np.array_equal(
            frigate_config.cameras["explicit"].motion.rasterized_mask,
            frigate_config.cameras["relative"].motion.rasterized_mask,
        )

    def test_default_input_args(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert "-rtsp_transport" in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]

    def test_ffmpeg_params_global(self):
        config = {
            "ffmpeg": {"input_args": "-re"},
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "objects": {
                        "track": ["person", "dog"],
                        "filters": {"dog": {"threshold": 0.7}},
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert "-re" in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]

    def test_ffmpeg_params_camera(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "ffmpeg": {"input_args": ["test"]},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ],
                        "input_args": ["-re"],
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "objects": {
                        "track": ["person", "dog"],
                        "filters": {"dog": {"threshold": 0.7}},
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert "-re" in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
        assert "test" not in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]

    def test_ffmpeg_params_input(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "ffmpeg": {"input_args": ["test2"]},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                                "input_args": "-re test",
                            }
                        ],
                        "input_args": "test3",
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "objects": {
                        "track": ["person", "dog"],
                        "filters": {"dog": {"threshold": 0.7}},
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert "-re" in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
        assert "test" in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
        assert "test2" not in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
        assert "test3" not in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]

    def test_inherit_clips_retention(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "record": {"alerts": {"retain": {"days": 20}}},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["back"].record.alerts.retain.days == 20

    def test_roles_listed_twice_throws_error(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "record": {
                "alerts": {
                    "retain": {
                        "days": 20,
                    }
                }
            },
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]},
                            {"path": "rtsp://10.0.0.1:554/video2", "roles": ["detect"]},
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }
        self.assertRaises(ValidationError, lambda: FrigateConfig(**config))

    def test_zone_matching_camera_name_throws_error(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "record": {
                "alerts": {
                    "retain": {
                        "days": 20,
                    }
                }
            },
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "zones": {"back": {"coordinates": "1,1,1,1,1,1"}},
                }
            },
        }
        self.assertRaises(ValidationError, lambda: FrigateConfig(**config))

    def test_zone_assigns_color_and_contour(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "record": {
                "alerts": {
                    "retain": {
                        "days": 20,
                    }
                }
            },
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "zones": {"test": {"coordinates": "1,1,1,1,1,1"}},
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert isinstance(
            frigate_config.cameras["back"].zones["test"].contour, np.ndarray
        )
        assert frigate_config.cameras["back"].zones["test"].color != (0, 0, 0)

    def test_zone_filter_area_percent_converts_to_pixels(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "record": {
                "alerts": {
                    "retain": {
                        "days": 20,
                    }
                }
            },
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "zones": {
                        "notification": {
                            "coordinates": "0.03,1,0.025,0,0.626,0,0.643,1",
                            "objects": ["person"],
                            "filters": {"person": {"min_area": 0.1}},
                        }
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        expected_min_area = int(1080 * 1920 * 0.1)
        assert (
            frigate_config.cameras["back"]
            .zones["notification"]
            .filters["person"]
            .min_area
            == expected_min_area
        )

    def test_zone_relative_matches_explicit(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "record": {
                "alerts": {
                    "retain": {
                        "days": 20,
                    }
                }
            },
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 400,
                        "width": 800,
                        "fps": 5,
                    },
                    "zones": {
                        "explicit": {
                            "coordinates": "0,0,200,100,600,300,800,400",
                        },
                        "relative": {
                            "coordinates": "0.0,0.0,0.25,0.25,0.75,0.75,1.0,1.0",
                        },
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert np.array_equal(
            frigate_config.cameras["back"].zones["explicit"].contour,
            frigate_config.cameras["back"].zones["relative"].contour,
        )

    def test_role_assigned_but_not_enabled(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                            {"path": "rtsp://10.0.0.1:554/record", "roles": ["record"]},
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        ffmpeg_cmds = frigate_config.cameras["back"].ffmpeg_cmds
        assert len(ffmpeg_cmds) == 1
        assert "clips" not in ffmpeg_cmds[0]["roles"]

    def test_max_disappeared_default(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "enabled": True,
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["back"].detect.max_disappeared == 5 * 5

    def test_motion_frame_height_wont_go_below_120(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["back"].motion.frame_height == 100

    def test_motion_contour_area_dynamic(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert round(frigate_config.cameras["back"].motion.contour_area) == 10

    def test_merge_labelmap(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "models": {"default": {"labelmap": {7: "truck"}}},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.models["default"].merged_labelmap[7] == "truck"

    def test_default_labelmap_empty(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.models["default"].merged_labelmap[0] == "person"

    def test_default_labelmap(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "models": {"default": {"width": 320, "height": 320}},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.models["default"].merged_labelmap[0] == "person"

    def test_plus_labelmap(self):
        with open(os.path.join(MODEL_CACHE_DIR, "test"), "w") as f:
            json.dump(self.plus_model_info, f)
        with open(os.path.join(MODEL_CACHE_DIR, "test.json"), "w") as f:
            json.dump(self.plus_model_info, f)

        config = {
            "mqtt": {"host": "mqtt"},
            "detectors": {"cpu": {"type": "cpu"}},
            "models": {"default": {"path": "plus://test"}},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.models["default"].merged_labelmap[0] == "amazon"

    def test_fails_on_invalid_role(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                            {
                                "path": "rtsp://10.0.0.1:554/video2",
                                "roles": ["clips"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        self.assertRaises(ValidationError, lambda: FrigateConfig(**config))

    def test_fails_on_missing_role(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                            {
                                "path": "rtsp://10.0.0.1:554/video2",
                                "roles": ["record"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "audio": {"enabled": True},
                }
            },
        }

        self.assertRaises(ValueError, lambda: FrigateConfig(**config))

    def test_works_on_missing_role_multiple_cams(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                            {
                                "path": "rtsp://10.0.0.1:554/video2",
                                "roles": ["record"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                },
                "cam2": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                            {
                                "path": "rtsp://10.0.0.1:554/video2",
                                "roles": ["record"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                },
            },
        }

        FrigateConfig(**config)

    def test_global_detect(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "detect": {"max_disappeared": 1},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["back"].detect.max_disappeared == 1
        assert frigate_config.cameras["back"].detect.height == 1080

    def test_default_detect(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 720,
                        "width": 1280,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["back"].detect.max_disappeared == 25
        assert frigate_config.cameras["back"].detect.height == 720

    def test_global_detect_merge(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "detect": {"max_disappeared": 1, "height": 720, "width": 1280},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["back"].detect.max_disappeared == 1
        assert frigate_config.cameras["back"].detect.height == 1080
        assert frigate_config.cameras["back"].detect.width == 1920

    def test_global_snapshots(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "snapshots": {"enabled": True},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "snapshots": {
                        "height": 100,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["back"].snapshots.enabled
        assert frigate_config.cameras["back"].snapshots.height == 100

    def test_default_snapshots(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["back"].snapshots.bounding_box
        assert frigate_config.cameras["back"].snapshots.quality == 60

    def test_global_snapshots_merge(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "snapshots": {"bounding_box": False, "height": 300},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "snapshots": {
                        "height": 150,
                        "enabled": True,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["back"].snapshots.bounding_box is False
        assert frigate_config.cameras["back"].snapshots.height == 150
        assert frigate_config.cameras["back"].snapshots.enabled

    def test_global_jsmpeg(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "live": {"quality": 4},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["back"].live.quality == 4

    def test_default_live(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["back"].live.quality == 8

    def test_global_live_merge(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "live": {"quality": 4, "height": 480},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "live": {
                        "quality": 7,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["back"].live.quality == 7
        assert frigate_config.cameras["back"].live.height == 480

    def test_global_timestamp_style(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "timestamp_style": {"position": "bl"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["back"].timestamp_style.position == "bl"

    def test_default_timestamp_style(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["back"].timestamp_style.position == "tl"

    def test_global_timestamp_style_merge(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "timestamp_style": {"position": "br", "thickness": 2},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "timestamp_style": {"position": "bl", "thickness": 4},
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["back"].timestamp_style.position == "bl"
        assert frigate_config.cameras["back"].timestamp_style.thickness == 4

    def test_allow_retain_to_be_a_decimal(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "snapshots": {"retain": {"default": 1.5}},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["back"].snapshots.retain.default == 1.5

    def test_fails_on_bad_camera_name(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "snapshots": {"retain": {"default": 1.5}},
            "cameras": {
                "back camer#": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        self.assertRaises(ValidationError, lambda: FrigateConfig(**config).cameras)

    def test_fails_on_bad_segment_time(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "record": {"enabled": True},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "output_args": {
                            "record": "-f segment -segment_time 70 -segment_format mp4 -reset_timestamps 1 -strftime 1 -c copy -an"
                        },
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ],
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        self.assertRaises(
            ValueError,
            lambda: FrigateConfig(**config).ffmpeg.output_args.record,
        )

    def test_fails_zone_defines_untracked_object(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "objects": {"track": ["person"]},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {
                                "path": "rtsp://10.0.0.1:554/video",
                                "roles": ["detect"],
                            },
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "zones": {
                        "steps": {
                            "coordinates": "0,0,0,0",
                            "objects": ["car", "person"],
                        },
                    },
                }
            },
        }

        self.assertRaises(ValueError, lambda: FrigateConfig(**config).cameras)

    def test_fails_duplicate_keys(self):
        raw_config = """
        cameras:
          test:
            ffmpeg:
              inputs:
                - one
                - two
              inputs:
                - three
                - four
        """

        self.assertRaises(
            DuplicateKeyError, lambda: FrigateConfig.parse_yaml(raw_config)
        )

    def test_object_filter_ratios_work(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "objects": {
                "track": ["person", "dog"],
                "filters": {"dog": {"min_ratio": 0.2, "max_ratio": 10.1}},
            },
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert "dog" in frigate_config.cameras["back"].objects.filters
        assert frigate_config.cameras["back"].objects.filters["dog"].min_ratio == 0.2
        assert frigate_config.cameras["back"].objects.filters["dog"].max_ratio == 10.1

    def test_valid_movement_weights(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "onvif": {
                        "autotracking": {
                            "movement_weights": "0, 1, 1.23, 2.34, 0.50, 1"
                        }
                    },
                }
            },
        }

        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["back"].onvif.autotracking.movement_weights == [
            "0.0",
            "1.0",
            "1.23",
            "2.34",
            "0.5",
            "1.0",
        ]

    def test_fails_invalid_movement_weights(self):
        config = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                    "onvif": {"autotracking": {"movement_weights": "1.234, 2.345a"}},
                }
            },
        }

        self.assertRaises(ValueError, lambda: FrigateConfig(**config))


class TestAttributeFilterDefaults(unittest.TestCase):
    """Verify attribute filter min_score handling at config load."""

    def setUp(self):
        self.minimal = {
            "mqtt": {"host": "mqtt"},
            "cameras": {
                "back": {
                    "ffmpeg": {
                        "inputs": [
                            {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
                        ]
                    },
                    "detect": {
                        "height": 1080,
                        "width": 1920,
                        "fps": 5,
                    },
                }
            },
        }

    def _build_config(self, object_filters: dict | None = None) -> FrigateConfig:
        config = deep_merge({}, self.minimal)
        if object_filters is not None:
            config.setdefault("objects", {})["filters"] = object_filters
        return FrigateConfig(**config)

    def test_attribute_with_no_filter_gets_default_min_score(self):
        """Attribute with no user-provided filter gets created with min_score=0.7."""
        config = self._build_config()
        face_filter = config.objects.filters.get("face")
        self.assertIsNotNone(face_filter)
        self.assertEqual(face_filter.min_score, 0.7)

    def test_attribute_filter_without_min_score_gets_bumped(self):
        """If user sets some FilterConfig field but not min_score, min_score is bumped to 0.7."""
        config = self._build_config({"face": {"min_area": 500}})
        face_filter = config.objects.filters["face"]
        self.assertEqual(face_filter.min_area, 500)
        self.assertEqual(face_filter.min_score, 0.7)

    def test_attribute_filter_explicit_min_score_half_is_preserved(self):
        """User-provided min_score=0.5 must NOT be silently rewritten to 0.7."""
        config = self._build_config({"face": {"min_score": 0.5}})
        face_filter = config.objects.filters["face"]
        self.assertEqual(face_filter.min_score, 0.5)

    def test_attribute_filter_explicit_min_score_other_value_is_preserved(self):
        """Sanity: explicit non-0.5 values pass through unchanged."""
        config = self._build_config({"face": {"min_score": 0.3}})
        face_filter = config.objects.filters["face"]
        self.assertEqual(face_filter.min_score, 0.3)


class TestMultiModelConfig(unittest.TestCase):
    """Tests for named models and detector instance assignment."""

    def setUp(self):
        self.base = {
            "mqtt": {"host": "mqtt"},
            "models": {
                "indoor": {"width": 320, "height": 320},
                "outdoor": {"width": 640, "height": 640},
            },
            "cameras": {
                "living_room": self._camera("indoor"),
                "driveway": self._camera("outdoor"),
            },
        }

    def _camera(self, model=None):
        camera = {
            "ffmpeg": {
                "inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
            },
            "detect": {"height": 1080, "width": 1920, "fps": 5},
        }

        if model:
            camera["detect"]["model"] = model

        return camera

    def test_single_model_resolves_implicitly(self):
        config = FrigateConfig(
            mqtt={"host": "mqtt"},
            models={"custom": {"width": 320, "height": 320}},
            cameras={"back": self._camera()},
        )
        assert config.cameras["back"].detect.model == "custom"

    def test_multiple_models_resolve_to_default(self):
        config = FrigateConfig(
            mqtt={"host": "mqtt"},
            models={
                "default": {"width": 320, "height": 320},
                "outdoor": {"width": 640, "height": 640},
            },
            cameras={"back": self._camera()},
        )
        assert config.cameras["back"].detect.model == "default"

    def test_multiple_models_without_default_requires_selection(self):
        config = self.base.copy()
        config["cameras"] = {"back": self._camera()}
        self.assertRaises(ValidationError, lambda: FrigateConfig(**config))

    def test_camera_references_missing_model(self):
        config = self.base.copy()
        config["cameras"] = {"back": self._camera("thermal")}
        self.assertRaises(ValidationError, lambda: FrigateConfig(**config))

    def test_global_detect_model_inherited_and_overridden(self):
        config = self.base.copy()
        config["detect"] = {"model": "indoor"}
        config["cameras"] = {
            "living_room": self._camera(),
            "driveway": self._camera("outdoor"),
        }
        frigate_config = FrigateConfig(**config)
        assert frigate_config.cameras["living_room"].detect.model == "indoor"
        assert frigate_config.cameras["driveway"].detect.model == "outdoor"

    def test_invalid_model_name(self):
        config = self.base.copy()
        config["models"] = {"bad name!": {"width": 320, "height": 320}}
        self.assertRaises(ValidationError, lambda: FrigateConfig(**config))

    def test_multi_model_detector_expands_instances(self):
        config = self.base.copy()
        config["detectors"] = {
            "ov0": {"type": "openvino", "device": "GPU"},
            "ov1": {"type": "openvino", "device": "GPU.1"},
        }
        frigate_config = FrigateConfig(**config)
        assert sorted(frigate_config.detector_instances.keys()) == [
            "ov0_indoor",
            "ov0_outdoor",
            "ov1_indoor",
            "ov1_outdoor",
        ]
        assert frigate_config.detector_instances["ov0_indoor"].model_key == "indoor"
        assert frigate_config.detector_instances["ov0_indoor"].model.width == 320
        assert frigate_config.detector_instances["ov0_outdoor"].model.width == 640

    def test_multi_model_detector_single_model_keeps_name(self):
        config = self.base.copy()
        config["models"] = {"default": {"width": 320, "height": 320}}
        config["cameras"] = {"back": self._camera()}
        config["detectors"] = {"ov": {"type": "openvino", "device": "GPU"}}
        frigate_config = FrigateConfig(**config)
        assert list(frigate_config.detector_instances.keys()) == ["ov"]
        assert frigate_config.detector_instances["ov"].model_key == "default"

    def test_single_model_detectors_round_robin(self):
        config = self.base.copy()
        config["detectors"] = {
            "coral0": {"type": "edgetpu", "device": "usb:0"},
            "coral1": {"type": "edgetpu", "device": "usb:1"},
            "coral2": {"type": "edgetpu", "device": "usb:2"},
        }
        frigate_config = FrigateConfig(**config)
        assignments = {
            key: instance.model_key
            for key, instance in frigate_config.detector_instances.items()
        }
        assert assignments == {
            "coral0": "indoor",
            "coral1": "outdoor",
            "coral2": "indoor",
        }

    def test_single_model_detectors_insufficient_coverage(self):
        config = self.base.copy()
        config["detectors"] = {"coral": {"type": "edgetpu", "device": "usb"}}
        self.assertRaises(ValidationError, lambda: FrigateConfig(**config))

    def test_single_model_detector_with_multi_model_detector(self):
        config = self.base.copy()
        config["detectors"] = {
            "coral": {"type": "edgetpu", "device": "usb"},
            "ov": {"type": "openvino", "device": "GPU"},
        }
        frigate_config = FrigateConfig(**config)
        assert frigate_config.detector_instances["coral"].model_key == "indoor"
        assert frigate_config.detector_instances["ov_indoor"].model_key == "indoor"
        assert frigate_config.detector_instances["ov_outdoor"].model_key == "outdoor"

    def test_unused_model_gets_no_instances(self):
        config = self.base.copy()
        config["models"] = {
            **config["models"],
            "thermal": {"width": 320, "height": 320},
        }
        frigate_config = FrigateConfig(**config)
        model_keys = {
            instance.model_key
            for instance in frigate_config.detector_instances.values()
        }
        assert "thermal" not in model_keys

    def test_model_path_ignored_when_detector_runs_multiple_models(self):
        config = self.base.copy()
        config["detectors"] = {
            "cpu": {"type": "cpu", "model_path": "/custom_model.tflite"}
        }
        frigate_config = FrigateConfig(**config)
        assert (
            frigate_config.detector_instances["cpu_indoor"].model.path
            == "/cpu_model.tflite"
        )

    def test_model_path_applied_when_detector_runs_one_model(self):
        config = self.base.copy()
        config["models"] = {"default": {"width": 320, "height": 320}}
        config["cameras"] = {"back": self._camera()}
        config["detectors"] = {
            "cpu": {"type": "cpu", "model_path": "/custom_model.tflite"}
        }
        frigate_config = FrigateConfig(**config)
        assert (
            frigate_config.detector_instances["cpu"].model.path
            == "/custom_model.tflite"
        )


if __name__ == "__main__":
    unittest.main(verbosity=2)
