import ast
import enum
import threading
import time
from abc import ABC, abstractmethod
from typing import Any, Literal

import numpy as np

from bec_lib import DeviceManagerBase, MessageEndpoints, bec_logger

from .errors import LimitError, ScanAbortion
from .path_optimization import PathOptimizerMixin
from .scan_stubs import ScanStubs

logger = bec_logger.logger


class ScanArgType(str, enum.Enum):
    DEVICE = "device"
    FLOAT = "float"
    INT = "int"
    BOOL = "boolean"
    STR = "str"
    LIST = "list"
    DICT = "dict"


def unpack_scan_args(scan_args: dict[str, Any]) -> list:
    """unpack_scan_args unpacks the scan arguments and returns them as a tuple.

    Args:
        scan_args (dict[str, Any]): scan arguments

    Returns:
        list: list of arguments
    """
    args = []
    if not scan_args:
        return args
    if not isinstance(scan_args, dict):
        return scan_args
    for cmd_name, cmd_args in scan_args.items():
        args.append(cmd_name)
        args.extend(cmd_args)
    return args


def get_2D_raster_pos(axis, snaked=True):
    """get_2D_raster_post calculates and returns the positions for a 2D

    snaked==True:
        ->->->->-
        -<-<-<-<-
        ->->->->-
    snaked==False:
        ->->->->-
        ->->->->-
        ->->->->-

    Args:
        axis (list): list of positions for each axis
        snaked (bool, optional): If true, the positions will be calculcated for a snake scan. Defaults to True.

    Returns:
        array: calculated positions
    """

    x_grid, y_grid = np.meshgrid(axis[0], axis[1])
    if snaked:
        y_grid.T[::2] = np.fliplr(y_grid.T[::2])
    x_flat = x_grid.T.ravel()
    y_flat = y_grid.T.ravel()
    positions = np.vstack((x_flat, y_flat)).T
    return positions


# pylint: disable=too-many-arguments
def get_fermat_spiral_pos(
    m1_start, m1_stop, m2_start, m2_stop, step=1, spiral_type=0, center=False
):
    """get_fermat_spiral_pos calculates and returns the positions for a Fermat spiral scan.

    Args:
        m1_start (float): start position motor 1
        m1_stop (float): end position motor 1
        m2_start (float): start position motor 2
        m2_stop (float): end position motor 2
        step (float, optional): Step size. Defaults to 1.
        spiral_type (float, optional): Angular offset in radians that determines the shape of the spiral.
        A spiral with spiral_type=2 is the same as spiral_type=0. Defaults to 0.
        center (bool, optional): Add a center point. Defaults to False.

    Returns:
        array: calculated positions in the form [[m1, m2], ...]
    """
    positions = []
    phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2.0) + spiral_type * np.pi

    start = int(not center)

    length_axis1 = abs(m1_stop - m1_start)
    length_axis2 = abs(m2_stop - m2_start)
    n_max = int(length_axis1 * length_axis2 * 3.2 / step / step)

    for ii in range(start, n_max):
        radius = step * 0.57 * np.sqrt(ii)
        if abs(radius * np.sin(ii * phi)) > length_axis1 / 2:
            continue
        if abs(radius * np.cos(ii * phi)) > length_axis2 / 2:
            continue
        positions.extend([(radius * np.sin(ii * phi), radius * np.cos(ii * phi))])
    return np.array(positions)


def get_round_roi_scan_positions(lx: float, ly: float, dr: float, nth: int, cenx=0, ceny=0):
    """
    get_round_roi_scan_positions calculates and returns the positions for a round scan in a rectangular region of interest.

    Args:
        lx (float): length in x
        ly (float): length in y
        dr (float): step size
        nth (int): number of angles in the inner ring
        cenx (int, optional): center in x. Defaults to 0.
        ceny (int, optional): center in y. Defaults to 0.

    Returns:
        array: calculated positions in the form [[x, y], ...]
    """
    positions = []
    nr = 1 + int(np.floor(max([lx, ly]) / dr))
    for ir in range(1, nr + 2):
        rr = ir * dr
        dth = 2 * np.pi / (nth * ir)
        pos = [
            (rr * np.cos(ith * dth) + cenx, rr * np.sin(ith * dth) + ceny)
            for ith in range(nth * ir)
            if np.abs(rr * np.cos(ith * dth)) < lx / 2 and np.abs(rr * np.sin(ith * dth)) < ly / 2
        ]
        positions.extend(pos)
    return np.array(positions)


def get_round_scan_positions(r_in: float, r_out: float, nr: int, nth: int, cenx=0, ceny=0):
    """
    get_round_scan_positions calculates and returns the positions for a round scan.

    Args:
        r_in (float): inner radius
        r_out (float): outer radius
        nr (int): number of radii
        nth (int): number of angles in the inner ring
        cenx (int, optional): center in x. Defaults to 0.
        ceny (int, optional): center in y. Defaults to 0.

    Returns:
        array: calculated positions in the form [[x, y], ...]

    """
    positions = []
    dr = (r_in - r_out) / nr
    for ir in range(1, nr + 2):
        rr = r_in + ir * dr
        dth = 2 * np.pi / (nth * ir)
        positions.extend(
            [
                (rr * np.sin(ith * dth) + cenx, rr * np.cos(ith * dth) + ceny)
                for ith in range(nth * ir)
            ]
        )
    return np.array(positions)


class RequestBase(ABC):
    """
    Base class for all scan requests.
    """

    scan_name = ""
    scan_report_hint = None
    arg_input = {"device": ScanArgType.DEVICE}
    arg_bundle_size = {"bundle": len(arg_input), "min": 1, "max": None}
    required_kwargs = []
    return_to_start_after_abort = False

    def __init__(
        self,
        *args,
        device_manager: DeviceManagerBase = None,
        monitored: list = None,
        parameter: dict = None,
        metadata: dict = None,
        **kwargs,
    ) -> None:
        super().__init__()
        self._shutdown_event = threading.Event()
        self.parameter = parameter if parameter is not None else {}
        self.caller_args = self.parameter.get("args", {})
        self.caller_kwargs = self.parameter.get("kwargs", {})
        self.metadata = metadata
        self.device_manager = device_manager
        self.connector = device_manager.connector
        self.DIID = 0
        self.scan_motors = []
        self.positions = []
        self._pre_scan_macros = []
        self._scan_report_devices = None
        self._get_scan_motors()
        self.readout_priority = {
            "monitored": monitored if monitored is not None else [],
            "baseline": [],
            "on_request": [],
            "async": [],
        }
        self.update_readout_priority()
        if metadata is None:
            self.metadata = {}
        self.stubs = ScanStubs(
            connector=self.device_manager.connector,
            device_msg_callback=self.device_msg_metadata,
            shutdown_event=self._shutdown_event,
        )

    @property
    def scan_report_devices(self):
        """devices to be included in the scan report"""
        if self._scan_report_devices is None:
            return self.readout_priority["monitored"]
        return self._scan_report_devices

    @scan_report_devices.setter
    def scan_report_devices(self, devices: list):
        self._scan_report_devices = devices

    def device_msg_metadata(self):
        default_metadata = {"readout_priority": "monitored", "DIID": self.DIID}
        metadata = {**default_metadata, **self.metadata}
        self.DIID += 1
        return metadata

    @staticmethod
    def _get_func_name_from_macro(macro: str):
        return ast.parse(macro).body[0].name

    def run_pre_scan_macros(self):
        """run pre scan macros if any"""
        macros = self.device_manager.connector.lrange(MessageEndpoints.pre_scan_macros(), 0, -1)
        for macro in macros:
            macro = macro.value.strip()
            func_name = self._get_func_name_from_macro(macro)
            exec(macro)
            eval(func_name)(self.device_manager.devices, self)

    def initialize(self):
        self.run_pre_scan_macros()

    def _check_limits(self):
        logger.debug("check limits")
        for ii, dev in enumerate(self.scan_motors):
            low_limit, high_limit = self.device_manager.devices[dev].limits
            if low_limit >= high_limit:
                # if both limits are equal or low > high, no restrictions ought to be applied
                return
            for pos in self.positions:
                pos_axis = pos[ii]
                if not low_limit <= pos_axis <= high_limit:
                    raise LimitError(
                        f"Target position {pos} for motor {dev} is outside of range: [{low_limit},"
                        f" {high_limit}]"
                    )

    def _get_scan_motors(self):
        if len(self.caller_args) == 0:
            return
        if self.arg_bundle_size.get("bundle"):
            self.scan_motors = list(self.caller_args.keys())
            return
        for motor in self.caller_args:
            if motor not in self.device_manager.devices:
                continue
            self.scan_motors.append(motor)

    def update_readout_priority(self):
        """update the readout priority for this request. Typically the monitored devices should also include the scan motors."""
        self.readout_priority["monitored"].extend(self.scan_motors)
        self.readout_priority["monitored"] = list(set(self.readout_priority["monitored"]))

    @abstractmethod
    def run(self):
        pass


class ScanBase(RequestBase, PathOptimizerMixin):
    """
    Base class for all scans. The following methods are called in the following order during the scan
    1. initialize
        - run_pre_scan_macros
    2. read_scan_motors
    3. prepare_positions
        - _calculate_positions
        - _optimize_trajectory
        - _set_position_offset
        - _check_limits
    4. open_scan
    5. stage
    6. run_baseline_reading
    7. pre_scan
    8. scan_core
    9. finalize
    10. unstage
    11. cleanup

    A subclass of ScanBase must implement the following methods:
    - _calculate_positions

    Attributes:
        scan_name (str): name of the scan
        scan_report_hint (str): hint for the scan report
        scan_type (str): scan type. Can be "step" or "fly"
        arg_input (list): list of scan argument types
        arg_bundle_size (dict):
            - bundle: number of arguments that are bundled together
            - min: minimum number of bundles
            - max: maximum number of bundles
        required_kwargs (list): list of required kwargs
        return_to_start_after_abort (bool): if True, the scan will return to the start position after an abort
    """

    scan_name = ""
    scan_report_hint = None
    scan_type = "step"
    arg_input = {"device": ScanArgType.DEVICE}
    arg_bundle_size = {"bundle": len(arg_input), "min": 1, "max": None}
    required_kwargs = ["required"]
    return_to_start_after_abort = True

    # perform pre-move action before the pre_scan trigger is sent
    pre_move = True

    # # synchronize primary readings with the scan motor readings. Set to False if the master device
    # # does not provide single readouts or are too fast to be synchronized with primary readings
    # enforce_sync = True

    def __init__(
        self,
        *args,
        device_manager: DeviceManagerBase = None,
        parameter: dict = None,
        exp_time: float = 0,
        readout_time: float = 0,
        acquisition_config: dict = None,
        settling_time: float = 0,
        relative: bool = False,
        burst_at_each_point: int = 1,
        frames_per_trigger: int = 1,
        optim_trajectory: Literal["corridor", None] = None,
        monitored: list = None,
        metadata: dict = None,
        **kwargs,
    ):
        super().__init__(
            *args,
            device_manager=device_manager,
            monitored=monitored,
            parameter=parameter,
            metadata=metadata,
            **kwargs,
        )
        self.DIID = 0
        self.point_id = 0
        self.exp_time = exp_time
        self.readout_time = readout_time
        self.acquisition_config = acquisition_config
        self.settling_time = settling_time
        self.relative = relative
        self.burst_at_each_point = burst_at_each_point
        self.frames_per_trigger = frames_per_trigger
        self.optim_trajectory = optim_trajectory
        self.burst_index = 0

        self.start_pos = np.repeat(0, len(self.scan_motors)).tolist()
        self.positions = []
        self.num_pos = None

        if self.scan_name == "":
            raise ValueError("scan_name cannot be empty")

        if acquisition_config is None or "default" not in acquisition_config:
            self.acquisition_config = {
                "default": {"exp_time": self.exp_time, "readout_time": self.readout_time}
            }

    @property
    def monitor_sync(self):
        """
        monitor_sync is a property that defines how monitored devices are synchronized.
        It can be either bec or the name of the device. If set to bec, the scan bundler
        will synchronize scan segments based on the bec triggered readouts. If set to a device name,
        the scan bundler will synchronize based on the readouts of the device, i.e. upon
        receiving a new readout of the device, cached monitored readings will be added
        to the scan segment.
        """
        return "bec"

    def read_scan_motors(self):
        """read the scan motors"""
        yield from self.stubs.read_and_wait(device=self.scan_motors, wait_group="scan_motor")

    @abstractmethod
    def _calculate_positions(self) -> None:
        """Calculate the positions"""
        pass

    def _optimize_trajectory(self):
        if not self.optim_trajectory:
            return
        if self.optim_trajectory == "corridor":
            self.positions = self.optimize_corridor(self.positions)
            return
        return

    def prepare_positions(self):
        """prepare the positions for the scan"""
        self._calculate_positions()
        self._optimize_trajectory()
        self.num_pos = len(self.positions) * self.burst_at_each_point
        yield from self._set_position_offset()
        self._check_limits()

    def open_scan(self):
        """open the scan"""
        positions = self.positions if isinstance(self.positions, list) else self.positions.tolist()
        yield from self.stubs.open_scan(
            scan_motors=self.scan_motors,
            readout_priority=self.readout_priority,
            num_pos=self.num_pos,
            positions=positions,
            scan_name=self.scan_name,
            scan_type=self.scan_type,
        )

    def stage(self):
        """call the stage procedure"""
        yield from self.stubs.stage()

    def run_baseline_reading(self):
        """perform a reading of all baseline devices"""
        yield from self.stubs.baseline_reading()

    def _set_position_offset(self):
        self.start_pos = []
        for dev in self.scan_motors:
            val = yield from self.stubs.send_rpc_and_wait(dev, "read")
            self.start_pos.append(val[dev].get("value"))
        if self.relative:
            self.positions += self.start_pos

    def close_scan(self):
        """close the scan"""
        yield from self.stubs.close_scan()

    def scan_core(self):
        """perform the scan core procedure"""
        for ind, pos in self._get_position():
            for self.burst_index in range(self.burst_at_each_point):
                yield from self._at_each_point(ind, pos)
            self.burst_index = 0

    def return_to_start(self):
        """return to the start position"""
        yield from self._move_and_wait(self.start_pos)

    def finalize(self):
        """finalize the scan"""
        yield from self.return_to_start()
        yield from self.stubs.wait(wait_type="read", group="primary", wait_group="readout_primary")
        yield from self.stubs.complete(device=None)

    def unstage(self):
        """call the unstage procedure"""
        yield from self.stubs.unstage()

    def cleanup(self):
        """call the cleanup procedure"""
        yield from self.close_scan()

    def _at_each_point(self, ind=None, pos=None):
        yield from self._move_and_wait(pos)
        if ind > 0:
            yield from self.stubs.wait(
                wait_type="read", group="primary", wait_group="readout_primary"
            )
        time.sleep(self.settling_time)
        yield from self.stubs.trigger(group="trigger", point_id=self.point_id)
        yield from self.stubs.wait(wait_type="trigger", group="trigger", wait_time=self.exp_time)
        yield from self.stubs.read(
            group="primary", wait_group="readout_primary", point_id=self.point_id
        )
        yield from self.stubs.wait(
            wait_type="read", group="scan_motor", wait_group="readout_primary"
        )

        self.point_id += 1

    def _move_and_wait(self, pos):
        if not isinstance(pos, list) and not isinstance(pos, np.ndarray):
            pos = [pos]
        if len(pos) == 0:
            return
        for ind, val in enumerate(self.scan_motors):
            yield from self.stubs.set(device=val, value=pos[ind], wait_group="scan_motor")

        yield from self.stubs.wait(wait_type="move", group="scan_motor", wait_group="scan_motor")

    def _get_position(self):
        for ind, pos in enumerate(self.positions):
            yield (ind, pos)

    def scan_report_instructions(self):
        yield None

    def pre_scan(self):
        """
        pre scan procedure. This method is called before the scan_core method and can be used to
        perform additional tasks before the scan is started. This
        """
        if self.pre_move and len(self.positions) > 0:
            yield from self._move_and_wait(self.positions[0])
        yield from self.stubs.pre_scan()

    def run(self):
        """run the scan. This method is called by the scan server and is the main entry point for the scan."""
        self.initialize()
        yield from self.read_scan_motors()
        yield from self.prepare_positions()
        yield from self.scan_report_instructions()
        yield from self.open_scan()
        yield from self.stage()
        yield from self.run_baseline_reading()
        yield from self.pre_scan()
        yield from self.scan_core()
        yield from self.finalize()
        yield from self.unstage()
        yield from self.cleanup()

    @classmethod
    def scan(cls, *args, **kwargs):
        scan = cls(args, **kwargs)
        yield from scan.run()


class SyncFlyScanBase(ScanBase, ABC):
    """
    Fly scan base class for all synchronous fly scans. A synchronous fly scan is a scan where the flyer is
    synced with the monitored devices.
    Classes inheriting from SyncFlyScanBase must at least implement the scan_core method and the monitor_sync property.
    """

    scan_type = "fly"
    pre_move = False

    def _get_flyer_status(self) -> list:
        flyer = self.scan_motors[0]
        connector = self.device_manager.connector

        pipe = connector.pipeline()
        connector.lrange(
            MessageEndpoints.device_req_status_container(self.metadata["RID"]), 0, -1, pipe
        )
        connector.get(MessageEndpoints.device_readback(flyer), pipe)
        return connector.execute_pipeline(pipe)

    @abstractmethod
    def scan_core(self):
        """perform the scan core procedure"""
        ############################################
        # Example of how to kickoff and wait for a flyer:
        ############################################

        # yield from self.stubs.kickoff(device=self.scan_motors[0], parameter=self.caller_kwargs)
        # yield from self.stubs.complete(device=self.scan_motors[0])
        # target_diid = self.DIID - 1

        # while True:
        #     status = self.stubs.get_req_status(
        #         device=self.scan_motors[0], RID=self.metadata["RID"], DIID=target_diid
        #     )
        #     progress = self.stubs.get_device_progress(
        #         device=self.scan_motors[0], RID=self.metadata["RID"]
        #     )
        #     if progress:
        #         self.num_pos = progress
        #     if status:
        #         break
        #     time.sleep(1)

    @property
    @abstractmethod
    def monitor_sync(self) -> str:
        """
        monitor_sync is the flyer that will be used to synchronize the monitor readings in the scan bundler.
        The return value should be the name of the flyer device.
        """

    def _calculate_positions(self) -> None:
        pass

    def read_scan_motors(self):
        yield None

    def prepare_positions(self):
        yield None


class AsyncFlyScanBase(SyncFlyScanBase):
    """
    Fly scan base class for all asynchronous fly scans. An asynchronous fly scan is a scan where the flyer is
    not synced with the monitored devices.
    Classes inheriting from AsyncFlyScanBase must at least implement the scan_core method.
    """

    @property
    def monitor_sync(self):
        return "bec"


class ScanStub(RequestBase):
    pass


class OpenScanDef(ScanStub):
    scan_name = "open_scan_def"
    scan_report_hint = None
    arg_input = {}
    arg_bundle_size = {"bundle": len(arg_input), "min": 0, "max": 0}

    def run(self):
        yield from self.stubs.open_scan_def()


class CloseScanDef(ScanStub):
    scan_name = "close_scan_def"
    scan_report_hint = "table"
    arg_input = {}
    arg_bundle_size = {"bundle": len(arg_input), "min": 0, "max": 0}

    def run(self):
        yield from self.stubs.close_scan_def()


class CloseScanGroup(ScanStub):
    scan_name = "close_scan_group"
    arg_input = {}
    arg_bundle_size = {"bundle": len(arg_input), "min": 0, "max": 0}

    def run(self):
        yield from self.stubs.close_scan_group()


class DeviceRPC(ScanStub):
    scan_name = "device_rpc"
    arg_input = [ScanArgType.DEVICE, ScanArgType.STR, ScanArgType.LIST, ScanArgType.DICT]
    arg_bundle_size = {"bundle": len(arg_input), "min": 1, "max": 1}
    scan_report_hint = None

    def _get_scan_motors(self):
        pass

    def run(self):
        # different to calling self.device_rpc, this procedure will not wait for a reply and therefore not check any errors.
        yield from self.stubs.rpc(device=self.parameter.get("device"), parameter=self.parameter)


class Move(RequestBase):
    scan_name = "mv"
    arg_input = {"device": ScanArgType.DEVICE, "target": ScanArgType.FLOAT}
    arg_bundle_size = {"bundle": len(arg_input), "min": 1, "max": None}
    scan_report_hint = None
    required_kwargs = ["relative"]

    def __init__(self, *args, relative=False, **kwargs):
        """
        Move device(s) to an absolute position
        Args:
            *args (Device, float): pairs of device / position arguments
            relative (bool): if True, move relative to current position

        Returns:
            ScanReport

        Examples:
            >>> scans.mv(dev.samx, 1, dev.samy,2)
        """
        super().__init__(**kwargs)
        self.relative = relative
        self.start_pos = np.repeat(0, len(self.scan_motors)).tolist()

    def _calculate_positions(self):
        self.positions = np.asarray([[val[0] for val in self.caller_args.values()]], dtype=float)

    def _at_each_point(self, pos=None):
        for ii, motor in enumerate(self.scan_motors):
            yield from self.stubs.set(
                device=motor,
                value=self.positions[0][ii],
                wait_group="scan_motor",
                metadata={"response": True},
            )

    def cleanup(self):
        pass

    def _set_position_offset(self):
        self.start_pos = []
        for dev in self.scan_motors:
            val = yield from self.stubs.send_rpc_and_wait(dev, "read")
            self.start_pos.append(val[dev].get("value"))
        if not self.relative:
            return
        self.positions += self.start_pos

    def prepare_positions(self):
        self._calculate_positions()
        yield from self._set_position_offset()
        self._check_limits()

    def scan_report_instructions(self):
        if not self.scan_report_hint:
            yield None
            return
        yield from self.stubs.scan_report_instruction(
            {
                "readback": {
                    "RID": self.metadata["RID"],
                    "devices": self.scan_motors,
                    "start": self.start_pos,
                    "end": self.positions[0],
                }
            }
        )

    def run(self):
        self.initialize()
        yield from self.prepare_positions()
        yield from self.scan_report_instructions()
        yield from self._at_each_point()


class UpdatedMove(Move):
    """
    Move device(s) to an absolute position and show live updates. This is a blocking call. For non-blocking use Move.
    Args:
        *args (Device, float): pairs of device / position arguments
        relative (bool): if True, move relative to current position

    Returns:
        ScanReport

    Examples:
        >>> scans.umv(dev.samx, 1, dev.samy,2)
    """

    scan_name = "umv"
    scan_report_hint = "readback"

    def _at_each_point(self, pos=None):
        for ii, motor in enumerate(self.scan_motors):
            yield from self.stubs.set(
                device=motor, value=self.positions[0][ii], wait_group="scan_motor"
            )

        for motor in self.scan_motors:
            yield from self.stubs.wait(wait_type="move", device=motor, wait_group="scan_motor")


class Scan(ScanBase):
    scan_name = "grid_scan"
    scan_report_hint = "table"
    arg_input = {
        "device": ScanArgType.DEVICE,
        "start": ScanArgType.FLOAT,
        "stop": ScanArgType.FLOAT,
        "steps": ScanArgType.INT,
    }
    arg_bundle_size = {"bundle": len(arg_input), "min": 2, "max": None}
    required_kwargs = ["relative"]

    def __init__(
        self,
        *args,
        exp_time: float = 0,
        settling_time: float = 0,
        relative: bool = False,
        burst_at_each_point: int = 1,
        **kwargs,
    ):
        """
        Scan two motors in a grid.

        Args:
            *args (Device, float, float, int): pairs of device / start / stop / steps arguments
            exp_time (float): exposure time in seconds. Default is 0.
            settling_time (float): settling time in seconds. Default is 0.
            relative (bool): if True, the motors will be moved relative to their current position. Default is False.
            burst_at_each_point (int): number of exposures at each point. Default is 1.

        Returns:
            ScanReport

        Examples:
            >>> scans.grid_scan(dev.motor1, -5, 5, 10, dev.motor2, -5, 5, 10, exp_time=0.1, relative=True)

        """
        super().__init__(**kwargs)
        self.relative = relative
        self.exp_time = exp_time
        self.settling_time = settling_time
        self.burst_at_each_point = burst_at_each_point
        self.axis = []

    def _calculate_positions(self):
        for _, val in self.caller_args.items():
            self.axis.append(np.linspace(val[0], val[1], val[2]))
        if len(self.axis) > 1:
            self.positions = get_2D_raster_pos(self.axis)
        else:
            self.positions = np.vstack(tuple(self.axis)).T


class FermatSpiralScan(ScanBase):
    scan_name = "fermat_scan"
    scan_report_hint = "table"
    required_kwargs = ["step", "relative"]
    arg_input = {
        "device": ScanArgType.DEVICE,
        "start": ScanArgType.FLOAT,
        "stop": ScanArgType.FLOAT,
    }
    arg_bundle_size = {"bundle": len(arg_input), "min": 2, "max": 2}

    def __init__(
        self,
        *args,
        step: float = 0.1,
        exp_time: float = 0,
        settling_time: float = 0,
        relative: bool = False,
        burst_at_each_point: int = 1,
        spiral_type: float = 0,
        optim_trajectory: Literal["corridor", None] = None,
        **kwargs,
    ):
        """
        A scan following Fermat's spiral.

        Args:
            *args: pairs of device / start position / end position arguments
            step (float): step size in motor units. Default is 0.1.
            exp_time (float): exposure time in seconds. Default is 0.
            settling_time (float): settling time in seconds. Default is 0.
            relative (bool): if True, the motors will be moved relative to their current position. Default is False.
            burst_at_each_point (int): number of exposures at each point. Default is 1.
            spiral_type (float): type of spiral to use. Default is 0.
            optim_trajectory (str): trajectory optimization method. Default is None. Options are "corridor" and "none".

        Returns:
            ScanReport

        Examples:
            >>> scans.fermat_scan(dev.motor1, -5, 5, dev.motor2, -5, 5, step=0.5, exp_time=0.1, relative=True, optim_trajectory="corridor")

        """
        super().__init__(**kwargs)
        self.axis = []
        self.step = step
        self.exp_time = exp_time
        self.settling_time = settling_time
        self.relative = relative
        self.burst_at_each_point = burst_at_each_point
        self.spiral_type = spiral_type
        self.optim_trajectory = optim_trajectory

    def _calculate_positions(self):
        params = list(self.caller_args.values())
        self.positions = get_fermat_spiral_pos(
            params[0][0],
            params[0][1],
            params[1][0],
            params[1][1],
            step=self.step,
            spiral_type=self.spiral_type,
            center=False,
        )


class RoundScan(ScanBase):
    scan_name = "round_scan"
    scan_report_hint = "table"
    required_kwargs = ["relative"]
    arg_input = {
        "motor_1": ScanArgType.DEVICE,
        "motor_2": ScanArgType.DEVICE,
        "inner_ring": ScanArgType.FLOAT,
        "outer_ring": ScanArgType.FLOAT,
        "number_of_rings": ScanArgType.INT,
        "number_of_positions_in_first_ring": ScanArgType.INT,
    }
    arg_bundle_size = {"bundle": len(arg_input), "min": 1, "max": 1}

    def __init__(
        self,
        motor_1,
        motor2,
        inner_ring: float,
        outer_ring: float,
        number_of_rings: int,
        pos_in_first_ring: int,
        *args,
        relative: bool = False,
        burst_at_each_point: int = 1,
        **kwargs,
    ):
        """
        A scan following a round shell-like pattern.

        Args:
            *args: motor1, motor2, inner ring, outer ring, number of rings, number of positions in the first ring
            relative (bool): if True, the motors will be moved relative to their current position. Default is False.
            burst_at_each_point (int): number of exposures at each point. Default is 1.

        Returns:
            ScanReport

        Examples:
            >>> scans.round_scan(dev.motor1, dev.motor2, 0, 25, 5, 3, exp_time=0.1, relative=True)

        """
        super().__init__(**kwargs)
        self.relative = relative
        self.burst_at_each_point = burst_at_each_point
        self.axis = []

    def _get_scan_motors(self):
        caller_args = list(self.caller_args.items())[0]
        self.scan_motors = [caller_args[0], caller_args[1][0]]

    def _calculate_positions(self):
        params = list(self.caller_args.values())[0]
        self.positions = get_round_scan_positions(
            r_in=params[1], r_out=params[2], nr=params[3], nth=params[4]
        )


class ContLineScan(ScanBase):
    scan_name = "cont_line_scan"
    scan_report_hint = "table"
    required_kwargs = ["steps", "relative"]
    arg_input = {
        "device": ScanArgType.DEVICE,
        "start": ScanArgType.FLOAT,
        "stop": ScanArgType.FLOAT,
    }
    arg_bundle_size = {"bundle": len(arg_input), "min": 1, "max": 1}
    scan_type = "step"

    def __init__(
        self,
        *args,
        exp_time: float = 0,
        steps: int = 10,
        relative: bool = False,
        offset: float = 100,
        burst_at_each_point: int = 1,
        **kwargs,
    ):
        """
        A line scan for one or more motors.

        Args:
            *args (Device, float, float): pairs of device / start position / end position
            exp_time (float): exposure time in seconds. Default is 0.
            steps (int): number of steps. Default is 10.
            relative (bool): if True, the motors will be moved relative to their current position. Default is False.
            burst_at_each_point (int): number of exposures at each point. Default is 1.
            offset (float): offset in motor units. Default is 100.

        Returns:
            ScanReport

        Examples:
            >>> scans.cont_line_scan(dev.motor1, -5, 5, steps=10, exp_time=0.1, relative=True)

        """
        super().__init__(**kwargs)
        self.axis = []
        self.exp_time = exp_time
        self.steps = steps
        self.relative = relative
        self.burst_at_each_point = burst_at_each_point
        self.offset = offset

    def _calculate_positions(self) -> None:
        for _, val in self.caller_args.items():
            ax_pos = np.linspace(val[0], val[1], self.steps)
            self.axis.append(ax_pos)
        self.positions = np.array(list(zip(*self.axis)))

    def _at_each_point(self):
        yield from self.stubs.trigger(group="trigger", point_id=self.point_id)
        yield from self.stubs.read(group="primary", wait_group="primary", point_id=self.point_id)
        self.point_id += 1

    def scan_core(self):
        yield from self._move_and_wait(self.positions[0] - self.offset)
        # send the slow motor on its way
        yield from self.stubs.set(
            device=self.scan_motors[0], value=self.positions[-1][0], wait_group="scan_motor"
        )

        while self.point_id < len(self.positions[:]):
            cont_motor_positions = self.device_manager.devices[self.scan_motors[0]].readback()

            if not cont_motor_positions:
                continue

            cont_motor_positions = cont_motor_positions.get("value")
            logger.debug(f"Current position of {self.scan_motors[0]}: {cont_motor_positions}")
            if np.isclose(cont_motor_positions, self.positions[self.point_id][0], atol=0.5):
                logger.debug(f"reading point {self.point_id}")
                yield from self._at_each_point()
                continue
            if cont_motor_positions > self.positions[self.point_id][0]:
                raise ScanAbortion(f"Skipped point {self.point_id + 1}")


class RoundScanFlySim(SyncFlyScanBase):
    scan_name = "round_scan_fly"
    scan_report_hint = "table"
    scan_type = "fly"
    pre_move = False
    required_kwargs = ["relative"]
    arg_input = {
        "flyer": ScanArgType.DEVICE,
        "inner_ring": ScanArgType.FLOAT,
        "outer_ring": ScanArgType.FLOAT,
        "number_of_rings": ScanArgType.INT,
        "number_of_positions_in_first_ring": ScanArgType.INT,
    }
    arg_bundle_size = {"bundle": len(arg_input), "min": 1, "max": 1}

    def __init__(self, *args, **kwargs):
        """
        A fly scan following a round shell-like pattern.

        Args:
            *args: motor1, motor2, inner ring, outer ring, number of rings, number of positions in the first ring
            relative: Start from an absolute or relative position
            burst: number of acquisition per point

        Returns:
            ScanReport

        Examples:
            >>> scans.round_scan_fly(dev.flyer_sim, 0, 50, 5, 3, exp_time=0.1, relative=True)

        """
        super().__init__(**kwargs)
        self.axis = []

    def _get_scan_motors(self):
        self.scan_motors = []
        self.flyer = list(self.caller_args.keys())[0]

    @property
    def monitor_sync(self):
        return self.flyer

    def prepare_positions(self):
        self._calculate_positions()
        self.num_pos = len(self.positions) * self.burst_at_each_point
        self._check_limits()
        yield None

    def finalize(self):
        yield

    def _calculate_positions(self):
        params = list(self.caller_args.values())[0]
        self.positions = get_round_scan_positions(
            r_in=params[0], r_out=params[1], nr=params[2], nth=params[3]
        )

    def scan_core(self):
        yield from self.stubs.kickoff(
            device=self.flyer,
            parameter={
                "num_pos": self.num_pos,
                "positions": self.positions.tolist(),
                "exp_time": self.exp_time,
            },
        )
        target_DIID = self.DIID - 1

        while True:
            yield from self.stubs.read_and_wait(group="primary", wait_group="readout_primary")
            status = self.device_manager.connector.get(MessageEndpoints.device_status(self.flyer))
            if status:
                device_is_idle = status.content.get("status", 1) == 0
                matching_RID = self.metadata.get("RID") == status.metadata.get("RID")
                matching_DIID = target_DIID == status.metadata.get("DIID")
                if device_is_idle and matching_RID and matching_DIID:
                    break

            time.sleep(1)
            logger.debug("reading monitors")


class RoundROIScan(ScanBase):
    scan_name = "round_roi_scan"
    scan_report_hint = "table"
    required_kwargs = ["dr", "nth", "relative"]
    arg_input = {
        "motor_1": ScanArgType.DEVICE,
        "motor_2": ScanArgType.DEVICE,
        "width_1": ScanArgType.FLOAT,
        "width_2": ScanArgType.FLOAT,
    }
    arg_bundle_size = {"bundle": len(arg_input), "min": 1, "max": 1}

    def __init__(
        self,
        *args,
        dr: float = 1,
        nth: int = 5,
        exp_time: float = 0,
        relative: bool = False,
        burst_at_each_point: int = 1,
        **kwargs,
    ):
        """
        A scan following a round-roi-like pattern.

        Args:
            *args: motor1, width for motor1, motor2, width for motor2,
            dr (float): shell width. Default is 1.
            nth (int): number of points in the first shell. Default is 5.
            exp_time (float): exposure time in seconds. Default is 0.
            relative (bool): Start from an absolute or relative position. Default is False.
            burst_at_each_point (int): number of acquisition per point. Default is 1.

        Returns:
            ScanReport

        Examples:
            >>> scans.round_roi_scan(dev.motor1, 20, dev.motor2, 20, dr=2, nth=3, exp_time=0.1, relative=True)

        """
        super().__init__(**kwargs)
        self.axis = []
        self.dr = dr
        self.nth = nth
        self.exp_time = exp_time
        self.relative = relative
        self.burst_at_each_point = burst_at_each_point

    def _calculate_positions(self) -> None:
        params = list(self.caller_args.values())
        self.positions = get_round_roi_scan_positions(
            lx=params[0][0], ly=params[1][0], dr=self.dr, nth=self.nth
        )


class ListScan(ScanBase):
    scan_name = "list_scan"
    scan_report_hint = "table"
    required_kwargs = ["relative"]
    arg_input = {"device": ScanArgType.DEVICE, "positions": ScanArgType.LIST}
    arg_bundle_size = {"bundle": len(arg_input), "min": 1, "max": None}

    def __init__(self, *args, parameter: dict = None, **kwargs):
        """
        A scan following the positions specified in a list.
        Please note that all lists must be of equal length.

        Args:
            *args: pairs of motors and position lists
            relative: Start from an absolute or relative position
            burst: number of acquisition per point

        Returns:
            ScanReport

        Examples:
            >>> scans.list_scan(dev.motor1, [0,1,2,3,4], dev.motor2, [4,3,2,1,0], exp_time=0.1, relative=True)

        """
        super().__init__(parameter=parameter, **kwargs)
        self.axis = []
        if len(set(len(entry[0]) for entry in self.caller_args.values())) != 1:
            raise ValueError("All position lists must be of equal length.")

    def _calculate_positions(self):
        self.positions = np.vstack(tuple(self.caller_args.values())).T.tolist()


class TimeScan(ScanBase):
    scan_name = "time_scan"
    scan_report_hint = "table"
    required_kwargs = ["points", "interval"]
    arg_input = {}
    arg_bundle_size = {"bundle": len(arg_input), "min": None, "max": None}

    def __init__(
        self,
        points: int,
        interval: float,
        exp_time: float = 0,
        burst_at_each_point: int = 1,
        **kwargs,
    ):
        """
        Trigger and readout devices at a fixed interval.
        Note that the interval time cannot be less than the exposure time.
        The effective "sleep" time between points is
            sleep_time = interval - exp_time

        Args:
            points: number of points
            interval: time interval between points
            exp_time: exposure time in s
            burst: number of acquisition per point

        Returns:
            ScanReport

        Examples:
            >>> scans.time_scan(points=10, interval=1.5, exp_time=0.1, relative=True)

        """
        super().__init__(**kwargs)
        self.axis = []
        self.points = points
        self.exp_time = exp_time
        self.burst_at_each_point = burst_at_each_point
        self.interval = interval
        self.interval -= self.exp_time

    def _calculate_positions(self) -> None:
        pass

    def prepare_positions(self):
        self.num_pos = self.points
        yield None

    def _at_each_point(self, ind=None, pos=None):
        if ind > 0:
            yield from self.stubs.wait(
                wait_type="read", group="primary", wait_group="readout_primary"
            )
        yield from self.stubs.trigger(group="trigger", point_id=self.point_id)
        yield from self.stubs.wait(wait_type="trigger", group="trigger", wait_time=self.exp_time)
        yield from self.stubs.read(
            group="primary", wait_group="readout_primary", point_id=self.point_id
        )
        yield from self.stubs.wait(wait_type="trigger", group="trigger", wait_time=self.interval)
        self.point_id += 1

    def scan_core(self):
        for ind in range(self.num_pos):
            yield from self._at_each_point(ind)


class MonitorScan(ScanBase):
    scan_name = "monitor_scan"
    scan_report_hint = "table"
    required_kwargs = ["relative"]
    arg_input = {
        "device": ScanArgType.DEVICE,
        "start": ScanArgType.FLOAT,
        "stop": ScanArgType.FLOAT,
    }
    arg_bundle_size = {"bundle": len(arg_input), "min": 1, "max": 1}
    scan_type = "fly"

    def __init__(self, device, start: float, stop: float, *args, relative: bool = False, **kwargs):
        """
        Readout all primary devices at each update of the monitored device.

        Args:
            device (Device): monitored device
            start (float): start position of the monitored device
            stop (float): stop position of the monitored device

        Returns:
            ScanReport

        Examples:
            >>> scans.monitor_scan(dev.motor1, -5, 5, exp_time=0.1, relative=True)

        """
        self.device = device
        self.start = start
        self.stop = stop
        super().__init__(**kwargs)
        self.axis = []
        self.relative = relative

    def _get_scan_motors(self):
        self.scan_motors = [self.device]
        self.flyer = self.device

    @property
    def monitor_sync(self):
        return self.flyer

    def _calculate_positions(self) -> None:
        self.positions = np.vstack(tuple(self.caller_args.values())).T.tolist()

    def prepare_positions(self):
        self._calculate_positions()
        self.num_pos = 0
        yield from self._set_position_offset()
        self._check_limits()

    def _get_flyer_status(self) -> list:
        connector = self.device_manager.connector

        pipe = connector.pipeline()
        connector.lrange(
            MessageEndpoints.device_req_status_container(self.metadata["RID"]), 0, -1, pipe
        )
        connector.get(MessageEndpoints.device_readback(self.flyer), pipe)
        return connector.execute_pipeline(pipe)

    def scan_core(self):
        yield from self.stubs.set(
            device=self.flyer, value=self.positions[0][0], wait_group="scan_motor"
        )
        yield from self.stubs.wait(wait_type="move", device=self.flyer, wait_group="scan_motor")

        # send the slow motor on its way
        yield from self.stubs.set(
            device=self.flyer,
            value=self.positions[1][0],
            wait_group="scan_motor",
            metadata={"response": True},
        )

        while True:
            move_completed, readback = self._get_flyer_status()

            if move_completed:
                break

            if not readback:
                continue
            readback = readback.content["signals"]
            yield from self.stubs.publish_data_as_read(
                device=self.flyer, data=readback, point_id=self.point_id
            )
            self.point_id += 1
            self.num_pos += 1


class Acquire(ScanBase):
    scan_name = "acquire"
    scan_report_hint = "table"
    required_kwargs = []
    arg_input = {}
    arg_bundle_size = {"bundle": len(arg_input), "min": None, "max": None}

    def __init__(self, *args, exp_time: float = 0, burst_at_each_point: int = 1, **kwargs):
        """
        A simple acquisition at the current position.

        Args:
            burst: number of acquisition per point

        Returns:
            ScanReport

        Examples:
            >>> scans.acquire(exp_time=0.1, relative=True)

        """
        super().__init__(**kwargs)
        self.exp_time = exp_time
        self.burst_at_each_point = burst_at_each_point
        self.axis = []

    def _calculate_positions(self) -> None:
        self.num_pos = self.burst_at_each_point

    def prepare_positions(self):
        self._calculate_positions()

    def _at_each_point(self, ind=None, pos=None):
        if ind > 0:
            yield from self.stubs.wait(
                wait_type="read", group="primary", wait_group="readout_primary"
            )
        yield from self.stubs.trigger(group="trigger", point_id=self.point_id)
        yield from self.stubs.wait(wait_type="trigger", group="trigger", wait_time=self.exp_time)
        yield from self.stubs.read(
            group="primary", wait_group="readout_primary", point_id=self.point_id
        )
        self.point_id += 1

    def scan_core(self):
        for self.burst_index in range(self.burst_at_each_point):
            yield from self._at_each_point(self.burst_index)
        self.burst_index = 0

    def run(self):
        self.initialize()
        self.prepare_positions()
        yield from self.open_scan()
        yield from self.stage()
        yield from self.run_baseline_reading()
        yield from self.pre_scan()
        yield from self.scan_core()
        yield from self.finalize()
        yield from self.unstage()
        yield from self.cleanup()


class LineScan(ScanBase):
    scan_name = "line_scan"
    scan_report_hint = "table"
    required_kwargs = ["steps", "relative"]
    arg_input = {
        "device": ScanArgType.DEVICE,
        "start": ScanArgType.FLOAT,
        "stop": ScanArgType.FLOAT,
    }
    arg_bundle_size = {"bundle": len(arg_input), "min": 1, "max": None}

    def __init__(
        self,
        *args,
        exp_time: float = 0,
        steps: int = None,
        relative: bool = False,
        burst_at_each_point: int = 1,
        **kwargs,
    ):
        """
        A line scan for one or more motors.

        Args:
            *args (Device, float, float): pairs of device / start position / end position
            exp_time (float): exposure time in s. Default: 0
            steps (int): number of steps. Default: 10
            relative (bool): if True, the start and end positions are relative to the current position. Default: False
            burst_at_each_point (int): number of acquisition per point. Default: 1

        Returns:
            ScanReport

        Examples:
            >>> scans.line_scan(dev.motor1, -5, 5, dev.motor2, -5, 5, steps=10, exp_time=0.1, relative=True)

        """
        super().__init__(**kwargs)
        self.exp_time = exp_time
        self.steps = steps
        self.relative = relative
        self.burst_at_each_point = burst_at_each_point
        self.axis = []

    def _calculate_positions(self) -> None:
        for _, val in self.caller_args.items():
            ax_pos = np.linspace(val[0], val[1], self.steps)
            self.axis.append(ax_pos)
        self.positions = np.array(list(zip(*self.axis)))


class ScanComponent(ScanBase):
    pass


class OpenInteractiveScan(ScanComponent):
    scan_name = "open_interactive_scan"
    scan_report_hint = ""
    required_kwargs = []
    arg_input = {"device": ScanArgType.DEVICE}
    arg_bundle_size = {"bundle": len(arg_input), "min": 1, "max": None}

    def __init__(self, *args, **kwargs):
        """
        An interactive scan for one or more motors.

        Args:
            *args: devices
            exp_time: exposure time in s
            steps: number of steps (please note: 5 steps == 6 positions)
            relative: Start from an absolute or relative position
            burst: number of acquisition per point

        Returns:
            ScanReport

        Examples:
            >>> scans.open_interactive_scan(dev.motor1, dev.motor2, exp_time=0.1)

        """
        super().__init__(**kwargs)
        self.axis = []

    def _calculate_positions(self):
        pass

    def _get_scan_motors(self):
        caller_args = list(self.caller_args.keys())
        self.scan_motors = caller_args

    def run(self):
        yield from self.stubs.open_scan_def()
        self.initialize()
        yield from self.read_scan_motors()
        yield from self.open_scan()
        yield from self.stage()
        yield from self.run_baseline_reading()


class AddInteractiveScanPoint(ScanComponent):
    scan_name = "interactive_scan_trigger"
    scan_report_hint = ""
    arg_input = {"device": ScanArgType.DEVICE}
    arg_bundle_size = {"bundle": len(arg_input), "min": 1, "max": None}

    def __init__(self, *args, **kwargs):
        """
        An interactive scan for one or more motors.

        Args:
            *args: devices
            exp_time: exposure time in s
            steps: number of steps (please note: 5 steps == 6 positions)
            relative: Start from an absolute or relative position
            burst: number of acquisition per point

        Returns:
            ScanReport

        Examples:
            >>> scans.interactive_scan_trigger()

        """
        super().__init__(**kwargs)
        self.axis = []

    def _calculate_positions(self):
        pass

    def _get_scan_motors(self):
        self.scan_motors = list(self.caller_args.keys())

    def _at_each_point(self, ind=None, pos=None):
        yield from self.stubs.trigger(group="trigger", point_id=self.point_id)
        yield from self.stubs.wait(wait_type="trigger", group="trigger", wait_time=self.exp_time)
        yield from self.stubs.read_and_wait(
            group="primary", wait_group="readout_primary", point_id=self.point_id
        )
        self.point_id += 1

    def run(self):
        yield from self.open_scan()
        yield from self._at_each_point()
        yield from self.close_scan()


class CloseInteractiveScan(ScanComponent):
    scan_name = "close_interactive_scan"
    scan_report_hint = ""
    arg_input = {}
    arg_bundle_size = {"bundle": len(arg_input), "min": None, "max": None}

    def __init__(self, *args, **kwargs):
        """
        An interactive scan for one or more motors.

        Args:
            *args: devices
            exp_time: exposure time in s
            steps: number of steps (please note: 5 steps == 6 positions)
            relative: Start from an absolute or relative position
            burst: number of acquisition per point

        Returns:
            ScanReport

        Examples:
            >>> scans.close_interactive_scan(dev.motor1, dev.motor2, exp_time=0.1)

        """
        super().__init__(**kwargs)
        self.axis = []

    def _calculate_positions(self):
        pass

    def run(self):
        yield from self.finalize()
        yield from self.unstage()
        yield from self.cleanup()
        yield from self.stubs.close_scan_def()
