import asyncio
import gzip
import json
import logging
import os
import sys
from collections.abc import Callable, Generator, Sequence
from contextlib import ExitStack
from functools import partial
from pathlib import Path
from types import ModuleType
from typing import Any, TypeVar
from unittest.mock import MagicMock, patch

import bluesky.plan_stubs as bps
import numpy
import pydantic
import pytest
from bluesky.simulators import RunEngineSimulator
from bluesky.utils import Msg, MsgGenerator
from dodal.beamlines import aithre, i03
from dodal.common.beamlines import beamline_utils
from dodal.common.beamlines.beamline_utils import clear_devices
from dodal.common.beamlines.commissioning_mode import set_commissioning_signal
from dodal.common.beamlines.config_client import get_config_client
from dodal.devices.aperturescatterguard import (
    AperturePosition,
    ApertureScatterguard,
    ApertureValue,
)
from dodal.devices.attenuator.attenuator import (
    BinaryFilterAttenuator,
    EnumFilterAttenuator,
)
from dodal.devices.attenuator.filter_selections import (
    I24FilterOneSelections,
    I24FilterTwoSelections,
)
from dodal.devices.backlight import Backlight
from dodal.devices.baton import Baton
from dodal.devices.beamlines.i03 import Beamstop, BeamstopPositions
from dodal.devices.beamlines.i03.beamsize import Beamsize
from dodal.devices.beamlines.i03.dcm import DCM
from dodal.devices.beamlines.i04.transfocator import Transfocator
from dodal.devices.beamsize.beamsize import BeamsizeBase
from dodal.devices.detector.detector_motion import DetectorMotion
from dodal.devices.eiger import EigerDetector
from dodal.devices.fast_grid_scan import FastGridScanCommon
from dodal.devices.flux import Flux
from dodal.devices.oav.oav_detector import OAV, OAVConfigBeamCentre
from dodal.devices.oav.oav_parameters import OAVParameters
from dodal.devices.oav.pin_image_recognition import PinTipDetection
from dodal.devices.robot import BartRobot, SampleLocation
from dodal.devices.s4_slit_gaps import S4SlitGaps
from dodal.devices.scintillator import Scintillator
from dodal.devices.smargon import Smargon
from dodal.devices.synchrotron import Synchrotron, SynchrotronMode
from dodal.devices.thawer import Thawer
from dodal.devices.undulator import UndulatorInKeV
from dodal.devices.webcam import Webcam
from dodal.devices.xbpm_feedback import XBPMFeedback
from dodal.devices.zebra.zebra import Zebra
from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutter
from dodal.devices.zocalo import ZocaloResults
from dodal.devices.zocalo.zocalo_results import _NO_SAMPLE_ID
from dodal.log import LOGGER as DODAL_LOGGER
from dodal.log import set_up_all_logging_handlers
from dodal.testing.fixtures.config_server import fake_config_server_get_file_contents
from dodal.utils import AnyDeviceFactory, collect_factories
from event_model.documents import Event, EventDescriptor, RunStart, RunStop
from ophyd_async.core import (
    AsyncStatus,
    Device,
    DeviceVector,
    Reference,
    completed_status,
    get_mock_put,
    init_devices,
    set_mock_value,
)
from ophyd_async.epics.core import epics_signal_rw
from ophyd_async.epics.motor import Motor
from ophyd_async.fastcs.panda import DatasetTable, PandaHdf5DatasetType
from PIL import Image
from pydantic.dataclasses import dataclass
from scanspec.core import Path as ScanPath
from scanspec.specs import Line

from mx_bluesky.common.external_interaction.callbacks.xray_centre.ispyb_callback import (
    GridscanPlane,
)
from mx_bluesky.common.parameters.constants import (
    DocDescriptorNames,
    EnvironmentConstants,
    PlanNameConstants,
)
from mx_bluesky.common.parameters.gridscan import SpecifiedThreeDGridScan
from mx_bluesky.common.parameters.rotation import RotationScan
from mx_bluesky.common.utils.exceptions import CrystalNotFoundError
from mx_bluesky.common.utils.log import (
    ALL_LOGGERS,
    ISPYB_ZOCALO_CALLBACK_LOGGER,
    LOGGER,
    NEXUS_LOGGER,
    _get_logging_dirs,
    do_default_logging_setup,
)
from mx_bluesky.hyperion.baton_handler import HYPERION_USER
from mx_bluesky.hyperion.experiment_plans.rotation_scan_plan import (
    RotationScanComposite,
)
from mx_bluesky.hyperion.parameters.device_composites import (
    HyperionFlyScanXRayCentreComposite,
)
from mx_bluesky.hyperion.parameters.gridscan import HyperionSpecifiedThreeDGridScan

pytest_plugins = ["tests.expeye_helpers"]

i03.DAQ_CONFIGURATION_PATH = "tests/test_data/test_daq_configuration"

TEST_GRAYLOG_PORT = 5555
TEST_VISIT = "cm1234-67"
TEST_CONTAINER = 8
TEST_SESSION_ID = 90
EXPECTED_START_TIME = "2024-02-08 14:03:59"
EXPECTED_END_TIME = "2024-02-08 14:04:01"
TEST_SAMPLE_ID = 364758
TEST_BARCODE = "12345A"

TEST_RESULT_LARGE = [
    {
        "centre_of_mass": [1, 2, 3],
        "max_voxel": [1, 2, 3],
        "max_count": 105062,
        "n_voxels": 35,
        "total_count": 2387574,
        "bounding_box": [[2, 2, 2], [8, 8, 7]],
        "sample_id": _NO_SAMPLE_ID,
    }
]
TEST_RESULT_MEDIUM = [
    {
        "centre_of_mass": [1, 2, 3],
        "max_voxel": [2, 4, 5],
        "max_count": 50000,
        "n_voxels": 35,
        "total_count": 100000,
        "bounding_box": [[1, 2, 3], [3, 4, 4]],
        "sample_id": _NO_SAMPLE_ID,
    }
]
TEST_RESULT_SMALL = [
    {
        "centre_of_mass": [1, 2, 3],
        "max_voxel": [1, 2, 3],
        "max_count": 1000,
        "n_voxels": 35,
        "total_count": 1000,
        "bounding_box": [[2, 2, 2], [3, 3, 3]],
        "sample_id": _NO_SAMPLE_ID,
    }
]
TEST_RESULT_BELOW_THRESHOLD = [
    {
        "centre_of_mass": [2, 3, 4],
        "max_voxel": [2, 3, 4],
        "max_count": 2,
        "n_voxels": 1,
        "total_count": 2,
        "bounding_box": [[1, 2, 3], [2, 3, 4]],
        "sample_id": _NO_SAMPLE_ID,
    }
]

# These are the uncorrected coordinate from zocalo
TEST_RESULT_IN_BOUNDS_TOP_LEFT_BOX = [
    {
        "centre_of_mass": [0.5, 0.5, 0.5],
        "max_voxel": [0, 0, 0],
        "max_count": 50000,
        "n_voxels": 35,
        "total_count": 100000,
        "bounding_box": [[0, 0, 0], [3, 4, 4]],
        "sample_id": _NO_SAMPLE_ID,
    }
]
# These are the uncorrected coordinate from zocalo
TEST_RESULT_IN_BOUNDS_TOP_LEFT_GRID_CORNER = [
    {
        "centre_of_mass": [0.0, 0.0, 0.0],
        "max_voxel": [0, 0, 0],
        "max_count": 50000,
        "n_voxels": 35,
        "total_count": 100000,
        "bounding_box": [[0, 0, 0], [3, 4, 4]],
        "sample_id": _NO_SAMPLE_ID,
    }
]
# These are the uncorrected coordinate from zocalo
TEST_RESULT_OUT_OF_BOUNDS_COM = [
    {
        "centre_of_mass": [-0.0001, -0.0001, -0.0001],
        "max_voxel": [0, 0, 0],
        "max_count": 50000,
        "n_voxels": 35,
        "total_count": 100000,
        "bounding_box": [[0, 0, 0], [3, 4, 4]],
        "sample_id": _NO_SAMPLE_ID,
    }
]
# These are the uncorrected coordinate from zocalo
TEST_RESULT_OUT_OF_BOUNDS_BB = [
    {
        "centre_of_mass": [0, 0, 0],
        "max_voxel": [0, 0, 0],
        "max_count": 50000,
        "n_voxels": 35,
        "total_count": 100000,
        "bounding_box": [[-1, -1, -1], [3, 4, 4]],
        "sample_id": _NO_SAMPLE_ID,
    }
]

MOCK_DAQ_CONFIG_PATH = "tests/test_data/test_daq_configuration"
mock_paths = [
    ("DAQ_CONFIGURATION_PATH", MOCK_DAQ_CONFIG_PATH),
    ("ZOOM_PARAMS_FILE", "tests/test_data/test_jCameraManZoomLevels.xml"),
    ("DISPLAY_CONFIG", f"{MOCK_DAQ_CONFIG_PATH}/display.configuration"),
]


@dataclass(frozen=True)
class SimConstants:
    BEAMLINE = "BL03S"
    # The following are values present in the system test ispyb database
    ST_VISIT = "cm14451-2"
    ST_SAMPLE_ID = 398810
    ST_MSP_SAMPLE_IDS = [398816, 398819]
    ST_CONTAINER_ID = 34864


@pytest.fixture(autouse=True, scope="session")
def ispyb_config_path():
    ispyb_config_path = os.environ.get(
        "ISPYB_CONFIG_PATH", "tests/test_data/test_config.cfg"
    )
    with patch.dict(os.environ, {"ISPYB_CONFIG_PATH": ispyb_config_path}):
        yield ispyb_config_path


@pytest.fixture(scope="session")
def active_device_factories() -> set[AnyDeviceFactory]:
    """Obtain the set of device factories that should have their caches cleared
    after every test invocation.
    Override this in sub-packages for the specific beamlines under test."""
    return device_factories_for_beamline(i03)


def device_factories_for_beamline(beamline_module: ModuleType) -> set[AnyDeviceFactory]:
    return {
        f
        for f in collect_factories(beamline_module, include_skipped=True).values()
        if hasattr(f, "cache_clear")
    }


@pytest.fixture(scope="function", autouse=True)
def clear_device_factory_caches_after_every_test(active_device_factories):
    yield None
    for f in active_device_factories:
        f.cache_clear()  # type: ignore


def replace_all_tmp_paths(d: dict[str, Any], tmp_path: Path):
    d = d.copy()
    for k, v in d.items():
        if isinstance(v, str):
            d[k] = v.replace("{tmp_data}", str(tmp_path))
        elif isinstance(v, dict):
            d[k] = replace_all_tmp_paths(v, tmp_path)
    return d


def raw_params_from_file(filename, tmp_path):
    with open(filename) as f:
        loads = replace_all_tmp_paths(json.loads(f.read()), tmp_path)
        return loads


def create_dummy_scan_spec():
    x_line = Line("sam_x", 0, 10, 10)
    y_line = Line("sam_y", 10, 20, 20)
    z_line = Line("sam_z", 30, 50, 30)

    specs = [y_line * ~x_line, z_line * ~x_line]
    specs = [ScanPath(spec.calculate()) for spec in specs]
    return [spec.consume().midpoints for spec in specs]


def _reset_loggers(loggers):
    """Clear all handlers and tear down the logging hierarchy, leave logger references intact."""
    clear_log_handlers(loggers)
    for logger in loggers:
        if logger.name != "Hyperion" and logger.name != "MX-Bluesky":
            # Hyperion parent is configured on module import, do not remove
            logger.parent = logging.getLogger()


def clear_log_handlers(loggers: Sequence[logging.Logger]):
    for logger in loggers:
        for handler in logger.handlers:
            handler.close()
        logger.handlers.clear()


def pytest_runtest_setup(item):
    markers = [m.name for m in item.own_markers]
    if item.config.getoption("logging") and "skip_log_setup" not in markers:
        if LOGGER.handlers == []:
            if DODAL_LOGGER.handlers == []:
                print("Initialising Hyperion logger for tests")
                do_default_logging_setup("dev_log.py", TEST_GRAYLOG_PORT, dev_mode=True)
        logging_path, _ = _get_logging_dirs(True)
        if ISPYB_ZOCALO_CALLBACK_LOGGER.handlers == []:
            print("Initialising ISPyB logger for tests")
            set_up_all_logging_handlers(
                ISPYB_ZOCALO_CALLBACK_LOGGER,
                logging_path,
                "hyperion_ispyb_callback.log",
                True,
                10000,
            )
        if NEXUS_LOGGER.handlers == []:
            print("Initialising nexus logger for tests")
            set_up_all_logging_handlers(
                NEXUS_LOGGER,
                logging_path,
                "hyperion_ispyb_callback.log",
                True,
                10000,
            )
    else:
        print("Skipping log setup for log test - deleting existing handlers")
        _reset_loggers([*ALL_LOGGERS, DODAL_LOGGER])
        handler = logging.StreamHandler()
        handler.setLevel(logging.INFO)
        handler.setFormatter(
            logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
        )
        DODAL_LOGGER.addHandler(handler)


def pytest_runtest_teardown(item):
    if "dodal.common.beamlines.beamline_utils" in sys.modules:
        sys.modules["dodal.common.beamlines.beamline_utils"].clear_devices()
    markers = [m.name for m in item.own_markers]
    if "skip_log_setup" in markers:
        _reset_loggers([*ALL_LOGGERS, DODAL_LOGGER])


def pass_on_mock(motor: Motor, call_log: MagicMock | None = None):
    def _pass_on_mock(value: float, wait: bool):
        set_mock_value(motor.user_readback, value)
        if call_log is not None:
            call_log(value, wait=wait)

    return _pass_on_mock


@pytest.fixture
def beamline_parameters():
    return fake_config_server_get_file_contents(
        "tests/test_data/test_beamline_parameters.txt", dict
    )


@pytest.fixture(autouse=True)
def i03_beamline_parameters():
    """Fix default i03 beamline parameters to refer to a test file not the /dls_sw folder"""
    with patch.dict(
        "dodal.common.beamlines.beamline_parameters.BEAMLINE_PARAMETER_PATHS",
        {"i03": "tests/test_data/test_beamline_parameters.txt"},
    ) as params:
        with ExitStack() as context_stack:
            for context_mgr in [
                patch(f"dodal.beamlines.i03.{name}", value, create=True)
                for name, value in mock_paths
            ]:
                context_stack.enter_context(context_mgr)
            yield params


@pytest.fixture(autouse=True)
def test_beamline_parameters():
    """Fix default test beamline parameters to refer to a test file not the /dls_sw folder"""
    with patch.dict(
        "dodal.common.beamlines.beamline_parameters.BEAMLINE_PARAMETER_PATHS",
        {"test": "tests/test_data/test_beamline_parameters.txt"},
    ) as params:
        yield params


@pytest.fixture
def hyperion_fgs_params(tmp_path):
    return HyperionSpecifiedThreeDGridScan(
        **(
            raw_params_from_file(
                "tests/test_data/parameter_json_files/good_test_parameters.json",
                tmp_path,
            )
        )
    )


@pytest.fixture
def eiger():
    eiger = i03.eiger.build(mock=True)
    eiger.stage = MagicMock(side_effect=lambda: completed_status())
    eiger.do_arm.set = MagicMock(side_effect=lambda _: completed_status())
    eiger.unstage = MagicMock(side_effect=lambda: completed_status())
    return eiger


@pytest.fixture
def smargon() -> Generator[Smargon, None, None]:
    smargon = i03.smargon.build(connect_immediately=True, mock=True)
    # Initial positions, needed for stub_offsets
    set_mock_value(smargon.stub_offsets.center_at_current_position.disp, 0)
    yield smargon
    clear_devices()


@pytest.fixture
def aithre_gonio():
    return aithre.goniometer.build(connect_immediately=True, mock=True)


@pytest.fixture
def zebra():
    return i03.zebra.build(connect_immediately=True, mock=True)


@pytest.fixture
def zebra_shutter():
    shutter = i03.sample_shutter.build(connect_immediately=True, mock=True)

    def put_sample_shutter(value, **kwargs):
        set_mock_value(shutter.position_readback, value)

    get_mock_put(shutter._manual_position_setpoint).side_effect = put_sample_shutter
    return shutter


@pytest.fixture
def backlight():
    backlight = i03.backlight.build(connect_immediately=True, mock=True)
    backlight.TIME_TO_MOVE_S = 0.001
    return backlight


@pytest.fixture
def baton():
    baton = i03.baton.build(connect_immediately=True, mock=True)
    set_mock_value(baton.requested_user, HYPERION_USER)
    set_mock_value(baton.current_user, HYPERION_USER)
    return baton


@pytest.fixture
def baton_in_commissioning_mode(baton: Baton):
    set_commissioning_signal(baton.commissioning)
    set_mock_value(baton.commissioning, True)
    yield baton
    set_commissioning_signal(None)


@pytest.fixture
def fast_grid_scan():
    scan = i03.zebra_fast_grid_scan.build(mock=True, connect_immediately=True)
    for signal in [scan.x_scan_valid, scan.y_scan_valid, scan.z_scan_valid]:
        set_mock_value(signal, 1)
    return scan


@pytest.fixture
def detector_motion():
    return i03.detector_motion.build(mock=True, connect_immediately=True)


@pytest.fixture
def undulator():
    undulator = i03.undulator.build(mock=True, connect_immediately=True)
    return undulator


@pytest.fixture
def s4_slit_gaps():
    return i03.s4_slit_gaps.build(mock=True, connect_immediately=True)


@pytest.fixture
def synchrotron():
    synchrotron = i03.synchrotron.build(mock=True, connect_immediately=True)
    set_mock_value(synchrotron.synchrotron_mode, SynchrotronMode.USER)
    set_mock_value(synchrotron.top_up_start_countdown, 10)
    return synchrotron


@pytest.fixture
def oav(test_config_files):
    parameters = OAVConfigBeamCentre(
        test_config_files["zoom_params_file"], test_config_files["display_config"]
    )
    oav = i03.oav.build(mock=True, connect_immediately=True, params=parameters)

    set_mock_value(oav.zoom_controller.level, "5.0x")
    set_mock_value(oav.grid_snapshot.x_size, 1024)
    set_mock_value(oav.grid_snapshot.y_size, 768)

    oav.snapshot.trigger = MagicMock(side_effect=lambda: completed_status())
    oav.grid_snapshot.trigger = MagicMock(side_effect=lambda: completed_status())
    yield oav


@pytest.fixture
def flux():
    return i03.flux.build(connect_immediately=True, mock=True)


@pytest.fixture
def pin_tip():
    return i03.pin_tip_detection.build(connect_immediately=True, mock=True)


@pytest.fixture
def ophyd_pin_tip_detection():
    return i03.pin_tip_detection.build(connect_immediately=True, mock=True)


@pytest.fixture()
def transfocator():
    with init_devices(mock=True):
        transfocator = Transfocator("", "")
    transfocator.set = MagicMock(side_effect=lambda _: completed_status())
    return transfocator


@pytest.fixture
def robot():
    robot = i03.robot.build(connect_immediately=True, mock=True)
    set_mock_value(robot.barcode, "BARCODE")

    @AsyncStatus.wrap
    async def fake_load(val: SampleLocation):
        if val is not None:  # type: ignore
            set_mock_value(robot.current_pin, val.pin)
            set_mock_value(robot.current_puck, val.puck)
            set_mock_value(robot.sample_id, await robot.next_sample_id.get_value())

    robot.set = MagicMock(side_effect=fake_load)
    return robot


@pytest.fixture
def scintillator(aperture_scatterguard):
    with init_devices(mock=True):
        scintillator = Scintillator(
            "",
            aperture_scatterguard=Reference(aperture_scatterguard),
            beamline_parameters=MagicMock(),
            name="scintillator",
        )
    return scintillator


@pytest.fixture
def attenuator():
    attenuator = i03.attenuator.build(connect_immediately=True, mock=True)
    set_mock_value(attenuator.actual_transmission, 0.49118047952)

    @AsyncStatus.wrap
    async def fake_attenuator_set(val):
        set_mock_value(attenuator.actual_transmission, val)

    attenuator.set = MagicMock(side_effect=fake_attenuator_set)

    yield attenuator


@pytest.fixture
def beamstop_phase1(
    beamline_parameters: dict[str, Any],
    sim_run_engine: RunEngineSimulator,
) -> Generator[Beamstop, Any, Any]:
    with patch(
        "dodal.beamlines.i03.get_beamline_parameters",
        return_value=beamline_parameters,
    ):
        beamstop = i03.beamstop.build(connect_immediately=True, mock=True)

        set_mock_value(beamstop.x_mm.user_readback, 1.52)
        set_mock_value(beamstop.y_mm.user_readback, 44.78)
        set_mock_value(beamstop.z_mm.user_readback, 30.0)

        # sim_run_engine.add_read_handler_for(
        #     beamstop.selected_pos, BeamstopPositions.DATA_COLLECTION
        # )
        # Can uncomment and remove below when https://github.com/bluesky/bluesky/issues/1906 is fixed
        def locate_beamstop(_):
            return {"readback": BeamstopPositions.DATA_COLLECTION}

        sim_run_engine.add_handler(
            "locate", locate_beamstop, beamstop.selected_pos.name
        )

        yield beamstop
        beamline_utils.clear_devices()


@pytest.fixture
def xbpm_feedback(
    baton: Baton,  # Ensure baton is cached with mock configuration
):
    xbpm = i03.xbpm_feedback.build(connect_immediately=True, mock=True)
    xbpm.trigger = MagicMock(side_effect=lambda: completed_status())
    yield xbpm
    beamline_utils.clear_devices()


def set_up_dcm(dcm: DCM, sim_run_engine: RunEngineSimulator):
    set_mock_value(dcm.energy_in_keV.user_readback, 12.7)
    set_mock_value(dcm.xtal_1.pitch_in_mrad.user_readback, 1)
    set_mock_value(dcm.crystal_metadata_d_spacing_a, 3.13475)
    sim_run_engine.add_read_handler_for(dcm.crystal_metadata_d_spacing_a, 3.13475)
    return dcm


@pytest.fixture
def dcm(sim_run_engine):
    dcm = i03.dcm.build(connect_immediately=True, mock=True)
    set_up_dcm(dcm, sim_run_engine)
    yield dcm


@pytest.fixture
def vfm():
    vfm = i03.vfm.build(connect_immediately=True, mock=True)
    vfm.bragg_to_lat_lookup_table_path = (
        "tests/test_data/test_beamline_vfm_lat_converter.txt"
    )
    return vfm


@pytest.fixture
def lower_gonio(
    sim_run_engine: RunEngineSimulator,
):
    lower_gonio = i03.lower_gonio.build(connect_immediately=True, mock=True)

    # Replace when https://github.com/bluesky/bluesky/issues/1906 is fixed
    def locate_gonio(_):
        return {"readback": 0}

    sim_run_engine.add_handler("locate", locate_gonio, lower_gonio.x.name)
    sim_run_engine.add_handler("locate", locate_gonio, lower_gonio.y.name)
    sim_run_engine.add_handler("locate", locate_gonio, lower_gonio.z.name)
    return lower_gonio


@pytest.fixture
def mirror_voltages():
    voltages = i03.mirror_voltages.build(connect_immediately=True, mock=True)
    voltages.voltage_lookup_table_path = "tests/test_data/test_mirror_focus.json"
    for vc in voltages.vertical_voltages.values():
        vc.set = MagicMock(side_effect=lambda _: completed_status())
    for vc in voltages.horizontal_voltages.values():
        vc.set = MagicMock(side_effect=lambda _: completed_status())
    yield voltages
    beamline_utils.clear_devices()


@pytest.fixture
def undulator_dcm(sim_run_engine, dcm, undulator):
    undulator_dcm = i03.undulator_dcm.build(
        connect_immediately=True,
        mock=True,
        daq_configuration_path="tests/test_data/test_daq_configuration",
        dcm=dcm,
        undulator=undulator,
    )
    set_up_dcm(undulator_dcm.dcm_ref(), sim_run_engine)
    yield undulator_dcm
    # beamline_utils.clear_devices()


@pytest.fixture
def webcam() -> Generator[Webcam, Any, Any]:
    webcam = i03.webcam.build(connect_immediately=True, mock=True)
    with patch.object(webcam, "_get_and_write_image"):
        yield webcam


@pytest.fixture
def thawer() -> Generator[Thawer, Any, Any]:
    yield i03.thawer.build(connect_immediately=True, mock=True)


@pytest.fixture
def sample_shutter() -> Generator[ZebraShutter, Any, Any]:
    yield i03.sample_shutter.build(connect_immediately=True, mock=True)


@pytest.fixture
async def aperture_scatterguard():
    positions = {
        ApertureValue.LARGE: AperturePosition(
            aperture_x=0,
            aperture_y=1,
            aperture_z=2,
            scatterguard_x=3,
            scatterguard_y=4,
            diameter=100,
        ),
        ApertureValue.MEDIUM: AperturePosition(
            aperture_x=5,
            aperture_y=6,
            aperture_z=2,
            scatterguard_x=8,
            scatterguard_y=9,
            diameter=50,
        ),
        ApertureValue.SMALL: AperturePosition(
            aperture_x=10,
            aperture_y=11,
            aperture_z=2,
            scatterguard_x=13,
            scatterguard_y=14,
            diameter=20,
        ),
        ApertureValue.OUT_OF_BEAM: AperturePosition(
            aperture_x=15,
            aperture_y=16,
            aperture_z=2,
            scatterguard_x=18,
            scatterguard_y=19,
            diameter=0,
        ),
        ApertureValue.PARKED: AperturePosition(
            aperture_x=20,
            aperture_y=25,
            aperture_z=0,
            scatterguard_x=36,
            scatterguard_y=56,
            diameter=0,
        ),
    }
    with (
        patch(
            "dodal.beamlines.i03.load_positions_from_beamline_parameters",
            return_value=positions,
        ),
        patch(
            "dodal.beamlines.i03.AperturePosition.tolerances_from_gda_params",
            return_value=AperturePosition(
                aperture_x=0.1,
                aperture_y=0.1,
                aperture_z=0.1,
                scatterguard_x=0.1,
                scatterguard_y=0.1,
            ),
        ),
    ):
        ap_sg = i03.aperture_scatterguard.build(connect_immediately=True, mock=True)

    await ap_sg.aperture.z.set(2)
    await ap_sg.selected_aperture.set(ApertureValue.SMALL)

    set_mock_value(ap_sg.aperture.small, 1)
    return ap_sg


@pytest.fixture()
async def beamsize(aperture_scatterguard: ApertureScatterguard):
    return Beamsize(aperture_scatterguard, name="beamsize")


@pytest.fixture()
def test_config_files():
    return {
        "zoom_params_file": "tests/test_data/test_jCameraManZoomLevels.xml",
        "oav_config_json": "tests/test_data/test_OAVCentring.json",
        "display_config": "tests/test_data/test_display.configuration",
    }


@pytest.fixture()
def fake_create_devices(
    beamstop_phase1: Beamstop,
    eiger: EigerDetector,
    smargon: Smargon,
    zebra: Zebra,
    detector_motion: DetectorMotion,
    aperture_scatterguard: ApertureScatterguard,
    backlight: Backlight,
):
    mock_omega_sets = MagicMock(side_effect=lambda _: completed_status())

    smargon.omega.velocity.set = mock_omega_sets
    smargon.omega.set = mock_omega_sets

    devices = {
        "beamstop": beamstop_phase1,
        "eiger": eiger,
        "gonio": smargon,
        "zebra": zebra,
        "detector_motion": detector_motion,
        "backlight": backlight,
        "ap_sg": aperture_scatterguard,
    }
    return devices


@pytest.fixture()
def fake_create_rotation_devices(
    beamstop_phase1: Beamstop,
    eiger: EigerDetector,
    smargon: Smargon,
    zebra: Zebra,
    detector_motion: DetectorMotion,
    backlight: Backlight,
    attenuator: BinaryFilterAttenuator,
    flux: Flux,
    undulator: UndulatorInKeV,
    aperture_scatterguard: ApertureScatterguard,
    synchrotron: Synchrotron,
    s4_slit_gaps: S4SlitGaps,
    dcm: DCM,
    robot: BartRobot,
    oav: OAV,
    sample_shutter: ZebraShutter,
    xbpm_feedback: XBPMFeedback,
    thawer: Thawer,
    beamsize: BeamsizeBase,
):
    set_mock_value(smargon.omega.max_velocity, 131)
    undulator.set = MagicMock(side_effect=lambda _: completed_status())
    return RotationScanComposite(
        attenuator=attenuator,
        backlight=backlight,
        beamsize=beamsize,
        beamstop=beamstop_phase1,
        dcm=dcm,
        detector_motion=detector_motion,
        eiger=eiger,
        flux=flux,
        gonio=smargon,
        undulator=undulator,
        aperture_scatterguard=aperture_scatterguard,
        synchrotron=synchrotron,
        s4_slit_gaps=s4_slit_gaps,
        zebra=zebra,
        robot=robot,
        oav=oav,
        sample_shutter=sample_shutter,
        xbpm_feedback=xbpm_feedback,
        thawer=thawer,
    )


@pytest.fixture
def zocalo():
    zoc = i03.zocalo.build(connect_immediately=True, mock=True)
    zoc.stage = MagicMock(side_effect=lambda: completed_status())
    zoc.unstage = MagicMock(side_effect=lambda: completed_status())
    return zoc


@pytest.fixture
async def enum_attenuator() -> EnumFilterAttenuator:
    with init_devices(mock=True):
        attenuator = EnumFilterAttenuator(
            "", filter_selection=(I24FilterOneSelections, I24FilterTwoSelections)
        )

    @AsyncStatus.wrap
    async def fake_attenuator_set(val):
        set_mock_value(attenuator.actual_transmission, val)

    attenuator.set = MagicMock(side_effect=fake_attenuator_set)
    return attenuator


@pytest.fixture
async def panda():
    class MockBlock(Device):
        def __init__(
            self,
            prefix: str,
            name: str = "",
            attributes: dict[str, Any] = {},  # noqa
        ):
            for name, dtype in attributes.items():
                setattr(self, name, epics_signal_rw(dtype, "", ""))
            super().__init__(name)

    def mock_vector_block(n, attributes):
        return DeviceVector(
            {i: MockBlock(f"{i}", f"{i}", attributes) for i in range(n)}
        )

    async def set_mock_blocks(
        panda, mock_blocks: dict[str, tuple[int, dict[str, Any]]]
    ):
        for name, block in mock_blocks.items():
            n, attrs = block
            block = mock_vector_block(n, attrs)
            await block.connect(mock=True)
            setattr(panda, name, block)

    async def create_mock_signals(devices_and_signals: dict[Device, dict[str, Any]]):
        for device, signals in devices_and_signals.items():
            for name, dtype in signals.items():
                sig = epics_signal_rw(dtype, name, name)
                await sig.connect(mock=True)
                setattr(device, name, sig)

    panda = i03.panda.build(connect_immediately=True, mock=True)
    await set_mock_blocks(
        panda,
        {
            "inenc": (8, {"setp": float}),
            "clock": (8, {"period": float}),
            "counter": (8, {"enable": str}),
        },
    )
    await create_mock_signals(
        {
            panda.pcap: {"enable": str},
            **{panda.pulse[i]: {"enable": str} for i in panda.pulse.keys()},
        }
    )

    set_mock_value(
        panda.data.datasets,
        DatasetTable(name=["name"], dtype=[PandaHdf5DatasetType.FLOAT_64]),
    )

    return panda


@pytest.fixture
def oav_parameters_for_rotation(test_config_files) -> OAVParameters:
    return OAVParameters(oav_config_json=test_config_files["oav_config_json"])


async def async_status_done():
    await asyncio.sleep(0)


def mock_gridscan_kickoff_complete(gridscan: FastGridScanCommon):
    gridscan.kickoff = MagicMock(return_value=async_status_done)
    gridscan.complete = MagicMock(return_value=async_status_done)


@pytest.fixture
def panda_fast_grid_scan():
    scan = i03.panda_fast_grid_scan.build(connect_immediately=True, mock=True)
    for signal in [scan.x_scan_valid, scan.y_scan_valid, scan.z_scan_valid]:
        set_mock_value(signal, 1)
    return scan


@pytest.fixture
async def hyperion_flyscan_xrc_composite(
    smargon: Smargon,
    hyperion_fgs_params: HyperionSpecifiedThreeDGridScan,
    attenuator,
    xbpm_feedback,
    synchrotron,
    aperture_scatterguard,
    zocalo,
    dcm,
    panda,
    backlight,
    s4_slit_gaps,
    fast_grid_scan,
    panda_fast_grid_scan,
    beamsize,
) -> HyperionFlyScanXRayCentreComposite:
    fake_composite = HyperionFlyScanXRayCentreComposite(
        aperture_scatterguard=aperture_scatterguard,
        attenuator=attenuator,
        backlight=backlight,
        dcm=dcm,
        # We don't use the eiger fixture here because .unstage() is used in some tests
        eiger=i03.eiger.build(mock=True),
        zebra_fast_grid_scan=fast_grid_scan,
        flux=i03.flux.build(connect_immediately=True, mock=True),
        s4_slit_gaps=s4_slit_gaps,
        gonio=smargon,
        undulator=i03.undulator.build(connect_immediately=True, mock=True),
        synchrotron=synchrotron,
        xbpm_feedback=xbpm_feedback,
        zebra=i03.zebra.build(connect_immediately=True, mock=True),
        zocalo=zocalo,
        panda=panda,
        panda_fast_grid_scan=panda_fast_grid_scan,
        robot=i03.robot.build(connect_immediately=True, mock=True),
        sample_shutter=i03.sample_shutter.build(connect_immediately=True, mock=True),
        beamsize=beamsize,
    )

    fake_composite.eiger.stage = MagicMock(side_effect=lambda: completed_status())
    # unstage should be mocked on a per-test basis because several rely on unstage
    fake_composite.eiger.set_detector_parameters(hyperion_fgs_params.detector_params)
    fake_composite.eiger.stop_odin_when_all_frames_collected = MagicMock()
    fake_composite.eiger.odin.check_and_wait_for_odin_state = lambda timeout: True

    test_result = {
        "centre_of_mass": [6, 6, 6],
        "max_voxel": [5, 5, 5],
        "max_count": 123456,
        "n_voxels": 321,
        "total_count": 999999,
        "bounding_box": [[3, 3, 3], [9, 9, 9]],
    }

    @AsyncStatus.wrap
    async def mock_complete(result):
        await fake_composite.zocalo._put_results([result], {"dcid": 0, "dcgid": 0})

    fake_composite.zocalo.trigger = MagicMock(
        side_effect=partial(mock_complete, test_result)
    )  # type: ignore
    fake_composite.zocalo.timeout_s = 3
    set_mock_value(fake_composite.gonio.x.max_velocity, 10)

    set_mock_value(fake_composite.robot.barcode, "BARCODE")

    return fake_composite


def fake_read(obj, initial_positions, _):
    initial_positions[obj] = 0
    yield Msg("null", obj)


def extract_metafile(input_filename, output_filename):
    with gzip.open(input_filename) as metafile_fo:
        with open(output_filename, "wb") as output_fo:
            output_fo.write(metafile_fo.read())


@pytest.fixture
def sim_run_engine():
    logging.getLogger("asyncio").setLevel(logging.DEBUG)
    return RunEngineSimulator()


class DocumentCapturer:
    """A utility which can be subscribed to the RunEngine in place of a callback in order
    to intercept documents and make assertions about their contents"""

    def __init__(self) -> None:
        self.docs_received: list[tuple[str, dict[str, Any]]] = []

    def __call__(self, *args: Any, **kwargs: Any) -> Any:
        self.docs_received.append((args[0], args[1]))

    @staticmethod
    def is_match(
        doc: tuple[str, dict[str, Any]],
        name: str,
        has_fields: Sequence[str] = [],
        matches_fields: dict[str, Any] = {},  # noqa
    ):
        """Returns True if the given document:
        - has the same name
        - contains all the fields in has_fields
        - contains all the fields in matches_fields with the same content"""

        return (
            doc[0] == name
            and all(f in doc[1].keys() for f in has_fields)
            and matches_fields.items() <= doc[1].items()
        )

    @staticmethod
    def get_matches(
        docs: list[tuple[str, dict[str, Any]]],
        name: str,
        has_fields: Sequence[str] = [],
        matches_fields: dict[str, Any] = {},  # noqa
    ):
        """Get all the docs from docs which:
        - have the same name
        - contain all the fields in has_fields
        - contain all the fields in matches_fields with the same content"""
        return list(
            filter(
                partial(
                    DocumentCapturer.is_match,
                    name=name,
                    has_fields=has_fields,
                    matches_fields=matches_fields,
                ),
                docs,
            )
        )

    @staticmethod
    def assert_doc(
        docs: list[tuple[str, dict[str, Any]]],
        name: str,
        has_fields: Sequence[str] = [],
        matches_fields: dict[str, Any] = {},  # noqa
        does_exist: bool = True,
    ):
        """Assert that a matching doc has been received by the sim,
        and returns the first match if it is meant to exist"""
        matches = DocumentCapturer.get_matches(docs, name, has_fields, matches_fields)
        if does_exist:
            assert matches
            return matches[0]
        else:
            assert matches == []

    @staticmethod
    def get_docs_until(
        docs: list[tuple[str, dict[str, Any]]],
        name: str,
        has_fields: Sequence[str] = [],
        matches_fields: dict[str, Any] = {},  # noqa
    ):
        """return all the docs from the list of docs until the first matching one"""
        for i, doc in enumerate(docs):
            if DocumentCapturer.is_match(doc, name, has_fields, matches_fields):
                return docs[: i + 1]
        raise ValueError(f"Doc {name=}, {has_fields=}, {matches_fields=} not found")

    @staticmethod
    def get_docs_from(
        docs: list[tuple[str, dict[str, Any]]],
        name: str,
        has_fields: Sequence[str] = [],
        matches_fields: dict[str, Any] = {},  # noqa
    ):
        """return all the docs from the list of docs after the first matching one"""
        for i, doc in enumerate(docs):
            if DocumentCapturer.is_match(doc, name, has_fields, matches_fields):
                return docs[i:]
        raise ValueError(f"Doc {name=}, {has_fields=}, {matches_fields=} not found")

    @staticmethod
    def assert_events_and_data_in_order(
        docs: list[tuple[str, dict[str, Any]]],
        match_data_keys_list: Sequence[Sequence[str]],
    ):
        for event_data_keys in match_data_keys_list:
            docs = DocumentCapturer.get_docs_from(docs, "event")
            doc = docs.pop(0)[1]["data"]
            assert all(k in doc.keys() for k in event_data_keys), (
                f"One of {event_data_keys=} not in {doc}"
            )


def assert_none_matching(
    messages: list[Msg],
    predicate: Callable[[Msg], bool],
):
    assert not list(filter(predicate, messages))


def pin_tip_edge_data():
    tip_x_px = 130
    tip_y_px = 200
    microns_per_pixel = 2.87  # from zoom levels .xml
    grid_width_px = int(400 / microns_per_pixel)
    target_grid_height_px = 140
    top_edge_data = ([0] * tip_x_px) + (
        [(tip_y_px - target_grid_height_px // 2)] * grid_width_px
    )
    bottom_edge_data = [0] * tip_x_px + [
        (tip_y_px + target_grid_height_px // 2)
    ] * grid_width_px
    top_edge_array = numpy.array(top_edge_data, dtype=numpy.uint32)
    bottom_edge_array = numpy.array(bottom_edge_data, dtype=numpy.uint32)
    return tip_x_px, tip_y_px, top_edge_array, bottom_edge_array


def thin_pin_edges():
    return pin_tip_edge_data()


def fat_pin_edges():
    tip_x_px, tip_y_px, top_edge_array, bottom_edge_array = pin_tip_edge_data()
    bottom_edge_array += 60
    return tip_x_px, tip_y_px, top_edge_array, bottom_edge_array


T = TypeVar("T")


def fake_generator(return_val: T = None) -> MsgGenerator[T]:
    yield from bps.null()
    return return_val


@pytest.fixture
def pin_tip_detection_with_found_pin(ophyd_pin_tip_detection: PinTipDetection):
    @AsyncStatus.wrap
    async def set_good_position():
        x, y, top_edge_array, bottom_edge_array = pin_tip_edge_data()
        set_mock_value(ophyd_pin_tip_detection.triggered_tip, numpy.array([x, y]))
        set_mock_value(ophyd_pin_tip_detection.triggered_top_edge, top_edge_array)  # type: ignore
        set_mock_value(ophyd_pin_tip_detection.triggered_bottom_edge, bottom_edge_array)  # type: ignore

    with patch.object(
        ophyd_pin_tip_detection,
        "trigger",
        side_effect=set_good_position,
    ):
        yield ophyd_pin_tip_detection


# Prevent pytest from catching exceptions when debugging in vscode so that break on
# exception works correctly (see: https://github.com/pytest-dev/pytest/issues/7409)
if os.getenv("PYTEST_RAISE", "0") == "1":

    @pytest.hookimpl(tryfirst=True)
    def pytest_exception_interact(call: pytest.CallInfo[Any]):
        if call.excinfo is not None:
            raise call.excinfo.value
        else:
            raise RuntimeError(
                f"{call} has no exception data, an unknown error has occurred"
            )

    @pytest.hookimpl(tryfirst=True)
    def pytest_internalerror(excinfo: pytest.ExceptionInfo[Any]):
        raise excinfo.value


def simulate_xrc_result(
    sim_run_engine: RunEngineSimulator,
    zocalo: ZocaloResults,
    test_results: Sequence[dict],
):
    for k in test_results[0].keys():
        sim_run_engine.add_read_handler_for(
            getattr(zocalo, k), numpy.array([r[k] for r in test_results])
        )


# The remaining code in this conftest is utility for external interaction tests. See https://github.com/DiamondLightSource/mx-bluesky/issues/699 for
# a better organisation of this


def default_raw_gridscan_params(
    tmp_path,
    json_file="tests/test_data/parameter_json_files/test_gridscan_param_defaults.json",
):
    return raw_params_from_file(json_file, tmp_path)


def _dummy_params(tmp_path):
    dummy_params = SpecifiedThreeDGridScan(
        **raw_params_from_file(
            "tests/test_data/parameter_json_files/test_gridscan_param_defaults.json",
            tmp_path,
        )
    )
    return dummy_params


def mx_acquisition_from_conn(mock_ispyb_conn) -> MagicMock:
    return mock_ispyb_conn.return_value.__enter__.return_value.mx_acquisition


def remap_upsert_columns(keys: Sequence[str], values: list):
    return dict(zip(keys, values, strict=False))


class OavGridSnapshotTestEvents:
    test_descriptor_document_oav_snapshot: EventDescriptor = {
        "uid": "b5ba4aec-de49-4970-81a4-b4a847391d34",
        "run_start": "d8bee3ee-f614-4e7a-a516-25d6b9e87ef3",
        "name": DocDescriptorNames.OAV_GRID_SNAPSHOT_TRIGGERED,
    }  # type: ignore
    test_event_document_oav_snapshot_xy: Event = {
        "descriptor": "b5ba4aec-de49-4970-81a4-b4a847391d34",
        "time": 1666604299.828203,
        "timestamps": {},
        "seq_num": 1,
        "uid": "29033ecf-e052-43dd-98af-c7cdd62e8174",
        "data": {
            "oav-grid_snapshot-top_left_x": 50,
            "oav-grid_snapshot-top_left_y": 100,
            "oav-grid_snapshot-num_boxes_x": 40,
            "oav-grid_snapshot-num_boxes_y": 20,
            "oav-microns_per_pixel_x": 1.58,
            "oav-microns_per_pixel_y": 1.58,
            "oav-beam_centre_i": 517,
            "oav-beam_centre_j": 350,
            "oav-x_direction": -1,
            "oav-y_direction": -1,
            "oav-z_direction": 1,
            "oav-grid_snapshot-box_width": 0.1 * 1000 / 1.25,  # size in pixels
            "oav-grid_snapshot-last_path_full_overlay": "test_1_y",
            "oav-grid_snapshot-last_path_outer": "test_2_y",
            "oav-grid_snapshot-last_saved_path": "test_3_y",
            "gonio-omega": 0,
            "gonio-chi": 0,
            "gonio-x": 0,
            "gonio-y": 0,
            "gonio-z": 0,
        },
    }
    test_event_document_oav_snapshot_xz: Event = {
        "descriptor": "b5ba4aec-de49-4970-81a4-b4a847391d34",
        "time": 1666604299.828203,
        "timestamps": {},
        "seq_num": 1,
        "uid": "29033ecf-e052-43dd-98af-c7cdd62e8174",
        "data": {
            "oav-grid_snapshot-top_left_x": 50,
            "oav-grid_snapshot-top_left_y": 0,
            "oav-grid_snapshot-num_boxes_x": 40,
            "oav-grid_snapshot-num_boxes_y": 10,
            "oav-grid_snapshot-box_width": 0.1 * 1000 / 1.25,  # size in pixels
            "oav-grid_snapshot-last_path_full_overlay": "test_1_z",
            "oav-grid_snapshot-last_path_outer": "test_2_z",
            "oav-grid_snapshot-last_saved_path": "test_3_z",
            "oav-microns_per_pixel_x": 1.58,
            "oav-microns_per_pixel_y": 1.58,
            "oav-beam_centre_i": 517,
            "oav-beam_centre_j": 350,
            "oav-x_direction": -1,
            "oav-y_direction": -1,
            "oav-z_direction": 1,
            "gonio-omega": -90,
            "gonio-chi": 30,
            "gonio-x": 0,
            "gonio-y": 0,
            "gonio-z": 0,
        },
    }


@pytest.fixture()
def test_event_data(tmp_path):
    return _TestEventData(tmp_path)


_UID_GRIDSCAN_OUTER = "d8bee3ee-f614-4e7a-a516-25d6b9e87ef3"
_UID_GRID_DETECT_AND_DO_GRIDSCAN = "41b82023-c271-449d-9543-260da8d85641"
_UID_ROTATION_MAIN = "2093c941-ded1-42c4-ab74-ea99980fbbfd"
_UID_DO_FGS = "636490db-83da-462c-a537-70e6fe416843"
_UID_ROBOT_UNLOAD = "9920cdec-0f13-442c-b985-3c076eeec61f"

_UID_ROBOT_UNLOAD_DESCRIPTOR = "34551604-5bfb-48e4-9eaa-9508261db03c"


class _TestEventData(OavGridSnapshotTestEvents):
    def __init__(self, tmp_path):
        self._tmp_path = tmp_path

    @property
    def test_grid_detect_and_gridscan_start_document(self) -> RunStart:
        return {  # type: ignore
            "uid": _UID_GRID_DETECT_AND_DO_GRIDSCAN,
            "time": 1666604299.6149616,
            "versions": {"ophyd": "1.6.4.post76+g0895f9f", "bluesky": "1.8.3"},
            "scan_id": 1,
            "plan_type": "generator",
            "subplan_name": PlanNameConstants.GRID_DETECT_AND_DO_GRIDSCAN,
            "mx_bluesky_parameters": _dummy_params(self._tmp_path).model_dump_json(),
        }

    @property
    def test_grid_detect_and_gridscan_stop_document_with_crystal_exception(
        self,
    ) -> RunStop:
        return {
            "run_start": _UID_GRID_DETECT_AND_DO_GRIDSCAN,
            "time": 1666604299.6149616,
            "uid": "65b2bde5-5740-42d7-9047-e860e06fbe15",
            "exit_status": "fail",
            "reason": f"{CrystalNotFoundError()}",
        }

    @property
    def test_rotation_start_main_document(self):
        return {
            "uid": _UID_ROTATION_MAIN,
            "subplan_name": PlanNameConstants.ROTATION_MAIN,
            "zocalo_environment": EnvironmentConstants.ZOCALO_ENV,
        }

    @property
    def test_gridscan_outer_start_document(self):
        return {
            "uid": _UID_GRIDSCAN_OUTER,
            "time": 1666604299.6149616,
            "versions": {"ophyd": "1.6.4.post76+g0895f9f", "bluesky": "1.8.3"},
            "scan_id": 1,
            "plan_type": "generator",
            "plan_name": PlanNameConstants.GRIDSCAN_OUTER,
            "subplan_name": PlanNameConstants.GRIDSCAN_OUTER,
            "zocalo_environment": EnvironmentConstants.ZOCALO_ENV,
            "mx_bluesky_parameters": _dummy_params(self._tmp_path).model_dump_json(),
        }

    @property
    def test_robot_unload_start_document(self):
        return {
            "uid": _UID_ROBOT_UNLOAD,
            "subplan_name": PlanNameConstants.ROBOT_UNLOAD,
            "metadata": {"visit": TEST_VISIT, "sample_id": TEST_SAMPLE_ID},
            "activate_callbacks": ["RobotLoadISPyBCallback"],
        }

    @property
    def test_rotation_event_document_during_data_collection(self) -> Event:
        return {
            "descriptor": "bd45c2e5-2b85-4280-95d7-a9a15800a78b",
            "time": 2666604299.928203,
            "data": {
                "aperture_scatterguard-aperture-x": 15,
                "aperture_scatterguard-aperture-y": 16,
                "aperture_scatterguard-aperture-z": 2,
                "aperture_scatterguard-scatterguard-x": 18,
                "aperture_scatterguard-scatterguard-y": 19,
                "aperture_scatterguard-selected_aperture": ApertureValue.MEDIUM,
                "aperture_scatterguard-diameter": 50,
                "attenuator-actual_transmission": 0.98,
                "flux-flux_reading": 9.81,
                "dcm-energy_in_keV": 11.105,
                "beamsize-x_um": 50.0,
                "beamsize-y_um": 20.0,
                "eiger_cam_roi_mode": False,
                "eiger-ispyb_detector_id": 78,
            },
            "timestamps": {"det1": 1666604299.8220396, "det2": 1666604299.8235943},
            "seq_num": 1,
            "uid": "2093c941-ded1-42c4-ab74-ea99980fbbfd",
            "filled": {},
        }

    @property
    def test_rotation_stop_main_document(self) -> RunStop:
        return {
            "run_start": _UID_ROTATION_MAIN,
            "time": 1666604300.0310638,
            "uid": "65b2bde5-5740-42d7-9047-e860e06fbe15",
            "exit_status": "success",
            "reason": "Test succeeded",
            "num_events": {"fake_ispyb_params": 1, "primary": 1},
        }

    @property
    def test_do_fgs_start_document(self) -> RunStart:
        specs = create_dummy_scan_spec()
        return {  # type: ignore
            "uid": _UID_DO_FGS,
            "time": 1666604299.6149616,
            "versions": {"ophyd": "1.6.4.post76+g0895f9f", "bluesky": "1.8.3"},
            "scan_id": 1,
            "plan_type": "generator",
            "subplan_name": PlanNameConstants.DO_FGS,
            "omega_to_scan_spec": {
                GridscanPlane.OMEGA_XY: specs[0],
                GridscanPlane.OMEGA_XZ: specs[1],
            },
        }

    @property
    def test_descriptor_document_oav_rotation_snapshot(self) -> EventDescriptor:
        return {
            "uid": "c7d698ce-6d49-4c56-967e-7d081f964573",
            "name": DocDescriptorNames.OAV_ROTATION_SNAPSHOT_TRIGGERED,
        }  # type: ignore

    @property
    def test_descriptor_document_pre_data_collection(self) -> EventDescriptor:
        return {
            "uid": "bd45c2e5-2b85-4280-95d7-a9a15800a78b",
            "name": DocDescriptorNames.HARDWARE_READ_PRE,
        }  # type: ignore

    @property
    def test_descriptor_document_during_data_collection(self) -> EventDescriptor:
        return {
            "uid": "bd45c2e5-2b85-4280-95d7-a9a15800a78b",
            "name": DocDescriptorNames.HARDWARE_READ_DURING,
        }  # type: ignore

    @property
    def test_descriptor_document_zocalo_hardware(self) -> EventDescriptor:
        return {
            "uid": "f082901b-7453-4150-8ae5-c5f98bb34406",
            "name": DocDescriptorNames.ZOCALO_HW_READ,
        }  # type: ignore

    @property
    def test_descriptor_document_robot_unload(self) -> EventDescriptor:
        return {
            "uid": _UID_ROBOT_UNLOAD_DESCRIPTOR,
            "name": DocDescriptorNames.ROBOT_UPDATE,
        }  # type: ignore

    @property
    def test_event_document_robot_unload(self) -> Event:
        return {
            "descriptor": _UID_ROBOT_UNLOAD_DESCRIPTOR,
            "time": 1666604299.828203,
            "data": {
                "robot-barcode": "123456",
                "robot-current_pin": 1,
                "robot-current_puck": 2,
                "webcam-last_saved_path": "blah",
                "oav-snapshot-last_saved_path": "blah",
            },
        }  # type: ignore

    @property
    def test_event_document_oav_rotation_snapshot(self) -> Event:
        return {
            "descriptor": "c7d698ce-6d49-4c56-967e-7d081f964573",
            "time": 1666604299.828203,
            "timestamps": {},
            "seq_num": 1,
            "uid": "32d7c25c-c310-4292-ac78-36ce6509be3d",
            "data": {"oav-snapshot-last_saved_path": "snapshot_0"},
        }

    @property
    def test_event_document_pre_data_collection(self) -> Event:
        return {
            "descriptor": "bd45c2e5-2b85-4280-95d7-a9a15800a78b",
            "time": 1666604299.828203,
            "data": {
                "s4_slit_gaps-xgap": 0.1234,
                "s4_slit_gaps-ygap": 0.2345,
                "synchrotron-synchrotron_mode": SynchrotronMode.USER,
                "undulator-current_gap": 1.234,
                "gonio-x": 0.158435435,
                "gonio-y": 0.023547354,
                "gonio-z": 0.00345684712,
                "dcm-energy_in_keV": 11.105,
            },
            "timestamps": {"det1": 1666604299.8220396, "det2": 1666604299.8235943},
            "seq_num": 1,
            "uid": "29033ecf-e052-43dd-98af-c7cdd62e8173",
            "filled": {},
        }

    @property
    def test_event_document_during_data_collection(self) -> Event:
        return {
            "descriptor": "bd45c2e5-2b85-4280-95d7-a9a15800a78b",
            "time": 2666604299.928203,
            "data": {
                "aperture_scatterguard-aperture-x": 15,
                "aperture_scatterguard-aperture-y": 16,
                "aperture_scatterguard-aperture-z": 2,
                "aperture_scatterguard-scatterguard-x": 18,
                "aperture_scatterguard-scatterguard-y": 19,
                "aperture_scatterguard-selected_aperture": ApertureValue.MEDIUM,
                "aperture_scatterguard-diameter": 50,
                "attenuator-actual_transmission": 1,
                "flux-flux_reading": 10,
                "dcm-energy_in_keV": 11.105,
                "eiger_bit_depth": "16",
                "beamsize-x_um": 50.0,
                "beamsize-y_um": 20.0,
                "eiger_cam_roi_mode": True,
                "eiger-ispyb_detector_id": 78,
            },
            "timestamps": {
                "det1": 1666604299.8220396,
                "det2": 1666604299.8235943,
                "eiger_bit_depth": 1666604299.8220396,
            },
            "seq_num": 1,
            "uid": "29033ecf-e052-43dd-98af-c7cdd62e8174",
            "filled": {},
        }

    @property
    def test_event_document_zocalo_hardware(self) -> Event:
        return {
            "uid": "29033ecf-e052-43dd-98af-c7cdd62e8175",
            "time": 1709654583.9770422,
            "data": {"eiger_odin_file_writer_id": "test_path"},
            "timestamps": {"eiger_odin_file_writer_id": 1666604299.8220396},
            "seq_num": 1,
            "filled": {},
            "descriptor": "f082901b-7453-4150-8ae5-c5f98bb34406",
        }

    @property
    def test_gridscan_outer_stop_document(self) -> RunStop:
        return {
            "run_start": _UID_GRIDSCAN_OUTER,
            "time": 1666604300.0310638,
            "uid": "65b2bde5-5740-42d7-9047-e860e06fbe15",
            "exit_status": "success",
            "reason": "",
            "num_events": {"fake_ispyb_params": 1, "primary": 1},
        }

    @property
    def test_grid_detect_and_gridscan_stop_document(self) -> RunStop:
        return {
            "run_start": _UID_GRID_DETECT_AND_DO_GRIDSCAN,
            "time": 1666604300.0310638,
            "uid": "65b2bde5-5740-42d7-9047-e860e06fbe15",
            "exit_status": "success",
            "reason": "",
        }

    @property
    def test_do_fgs_stop_document(self) -> RunStop:
        return {
            "run_start": _UID_DO_FGS,
            "time": 1666604300.0310638,
            "uid": "65b2bde5-5740-42d7-9047-e860e06fbe15",
            "exit_status": "success",
            "reason": "",
            "num_events": {"fake_ispyb_params": 1, "primary": 1},
        }

    @property
    def test_grid_detect_and_gridscan_failed_stop_document(self) -> RunStop:
        return {
            "run_start": _UID_GRID_DETECT_AND_DO_GRIDSCAN,
            "time": 1666604300.0310638,
            "uid": "65b2bde5-5740-42d7-9047-e860e06fbe15",
            "exit_status": "fail",
            "reason": "could not connect to devices",
            "num_events": {"fake_ispyb_params": 1, "primary": 1},
        }

    @property
    def test_robot_unload_stop_document(self) -> RunStop:
        return {
            "run_start": _UID_ROBOT_UNLOAD,
            "time": 1666604300.0310638,
            "uid": "863e29d5-0b4e-4a54-bd78-02b23f79309e",
            "exit_status": "success",
            "reason": "",
        }


class TestData(OavGridSnapshotTestEvents):
    DUMMY_TIME_STRING: str = "1970-01-01 00:00:00"

    test_result_large = [
        {
            "centre_of_mass": [1, 2, 3],
            "max_voxel": [1, 2, 3],
            "max_count": 105062,
            "n_voxels": 35,
            "total_count": 2387574,
            "bounding_box": [[2, 2, 2], [8, 8, 7]],
        }
    ]
    test_result_medium = [
        {
            "centre_of_mass": [1, 2, 3],
            "max_voxel": [2, 4, 5],
            "max_count": 50000,
            "n_voxels": 35,
            "total_count": 100000,
            "bounding_box": [[1, 2, 3], [3, 4, 4]],
        }
    ]
    test_result_small = [
        {
            "centre_of_mass": [1, 2, 3],
            "max_voxel": [1, 2, 3],
            "max_count": 1000,
            "n_voxels": 35,
            "total_count": 1000,
            "bounding_box": [[2, 2, 2], [3, 3, 3]],
        }
    ]
    test_result_below_threshold = [
        {
            "centre_of_mass": [2, 3, 4],
            "max_voxel": [2, 3, 4],
            "max_count": 2,
            "n_voxels": 1,
            "total_count": 2,
            "bounding_box": [[1, 2, 3], [2, 3, 4]],
        }
    ]


@pytest.fixture
def dummy_rotation_params(tmp_path):
    dummy_params = RotationScan(
        **raw_params_from_file(
            "tests/test_data/parameter_json_files/good_test_one_multi_rotation_scan_parameters.json",
            tmp_path,
        )
    )
    return dummy_params


@pytest.fixture
def test_rotation_params(tmp_path):
    scan = RotationScan(
        **(
            raw_params_from_file(
                "tests/test_data/parameter_json_files/good_test_one_multi_rotation_scan_parameters.json",
                tmp_path,
            )
        )
    )
    return scan


@pydantic.dataclasses.dataclass(config={"arbitrary_types_allowed": True})
class XBPMAndTransmissionWrapperComposite:
    undulator: UndulatorInKeV
    xbpm_feedback: XBPMFeedback
    attenuator: BinaryFilterAttenuator
    dcm: DCM


@pytest.fixture
def xbpm_and_transmission_wrapper_composite(
    undulator: UndulatorInKeV,
    xbpm_feedback: XBPMFeedback,
    attenuator: BinaryFilterAttenuator,
    dcm: DCM,
) -> XBPMAndTransmissionWrapperComposite:
    return XBPMAndTransmissionWrapperComposite(
        undulator, xbpm_feedback, attenuator, dcm
    )


def assert_images_pixelwise_equal(actual, expected):
    with Image.open(expected) as expected_image:
        expected_bytes = expected_image.tobytes()
        with Image.open(actual) as actual_image:
            actual_bytes = actual_image.tobytes()
            # assert tries to be clever and takes forever if this is inlined and
            # the comparison fails
            bytes_expected_bytes = actual_bytes == expected_bytes
            assert bytes_expected_bytes, (
                f"Actual and expected images differ, {actual} != {expected}"
            )


@pytest.fixture(autouse=True)
def mock_alert_service():
    with patch(
        "mx_bluesky.common.external_interaction.alerting._service._alert_service",
        create=True,
    ) as service:
        yield service


@pytest.fixture()
def patch_beamline_env_variable(monkeypatch):
    monkeypatch.setenv("BEAMLINE", "dev")


@pytest.fixture(autouse=True)
def clear_cache():
    get_config_client.cache_clear()


@pytest.fixture(autouse=True)
def patch_get_hyperion_feature_settings():
    fake_path = "tests/test_data/test_domain_properties"
    with patch(
        "mx_bluesky.hyperion.external_interaction.config_server.GDA_DOMAIN_PROPERTIES_PATH",
        str(fake_path),
    ):
        yield
