# Copyright © 2024 Quantum Interface (quantuminterface@ipe.kit.edu)
# Lukas Scheller, IPE, Karlsruhe Institute of Technology
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
"""
This module defines available commands that are the building block of every QiJob.
Classes found herein are usable as API, for example when lowering from a higher-level language.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol

import qiclib.packages.utility as util
from qiclib.code.qi_pulse import QiPulse
from qiclib.code.qi_result import QiResult
from qiclib.code.qi_types import QiType, _TypeDefiningUse
from qiclib.code.qi_var_definitions import (
    QiCellProperty,
    QiCondition,
    QiExpression,
    QiVariableSet,
    _QiCalcBase,
    _QiConstValue,
    _QiVariableBase,
)
from qiclib.code.qi_visitor import QiCMContainedCellVisitor, QiVarInForRange
from qiclib.hardware import digital_trigger

if TYPE_CHECKING:
    from qiclib.code.qi_jobs import QiCell, QiCoupler
    from qiclib.code.qi_seq_instructions import SequencerInstruction
    from qiclib.code.qi_visitor import QiCommandVisitor


class QiCommand(ABC):
    """
    This is the base class of every command.

    A command knows the `QiCells` that are relevant for that command and the variables associated for analysis purposes.
    Furthermore, the `QiCommandVisitor` class can be used to traverse the command structure.
    """

    def __init__(self) -> None:
        self._associated_variable_set = QiVariableSet()
        self._relevant_cells: set[QiCell] = set()

    @abstractmethod
    def accept(self, visitor: QiCommandVisitor, *args, **kwargs):
        """
        Accepts a visitor.
        This method is overwritten in every child class to enable traversal of the tree by subclassing
        `QiVistor`. Additional arguments and keyword arguments can be passed to this method, however their
        interpretation is dependent on the actual implementation.

        :param visitor: The visitor to accept.
        """

    @abstractmethod
    def _stringify(self) -> str:
        pass

    def is_variable_relevant(self, variable: _QiVariableBase) -> bool:
        """Returns whether a variable is relevant for this command.

        :param variable: the variable to check
        :return: `true`, if this variable is relevant, false otherwise.
        """
        return variable in self._associated_variable_set

    def add_associated_variable(self, x):
        """Adds a variable to the set of variables that are associated to this command.

        :param x: the variable to add
        """
        if isinstance(x, _QiVariableBase):
            self._associated_variable_set.add(x)

    def __str__(self) -> str:
        return "QiCommand"


class QiCellCommand(QiCommand, ABC):
    """
    Cell commands operate on one cell exclusively.
    Concrete examples are the various `Play` or `PlayReadout` commands

    :param cell: The target cell
    """

    def __init__(self, cell: QiCell):
        super().__init__()
        self.cell = cell
        self._relevant_cells.add(cell)

    def accept(self, visitor: QiCommandVisitor, *args, **kwargs):
        return visitor.visit_cell_command(self, *args, **kwargs)


class QiVariableCommand(QiCommand, ABC):
    """
    Base class of commands that have an associated variable.
    Examples are the `Assign` or `Declare` commands.

    :param var: The relevant variable
    """

    def __init__(self, var: _QiVariableBase):
        super().__init__()
        self.var = var

    def accept(self, visitor: QiCommandVisitor, *args, **kwargs):
        return visitor.visit_variable_command(self, *args, **kwargs)


class QiTriggerCommand(Protocol):
    """
    Common interface for all commands that can cause a trigger.
    """

    trigger_index: int


class DigitalTriggerCommand(QiCellCommand, QiTriggerCommand):
    """
    Command generated by :meth:`DigitalTrigger`.
    This command issues a digital pulse.

    :param cell: The cell that issues the trigger command
    :param outputs: The outputs to activate
    :param length: The length of the trigger pulse in seconds.
    """

    def __init__(self, cell: QiCell, outputs: list[int], length: float):
        super().__init__(cell)
        self.trigger_index = cell.add_digital_trigger(
            digital_trigger.TriggerSet(
                duration=length, outputs=outputs, continuous=False
            )
        )
        self.length = length

    def _stringify(self) -> str:
        return f"DigitalTrigger({self.cell}, {self.trigger_index}, {self.length})"


class WaitCommand(QiCellCommand):
    """
    Waits for a specified amount of time.

    :param cell: The cell that should wait
    :param length: How long to wait for
    """

    def __init__(self, cell, length: QiExpression | QiCellProperty):
        super().__init__(cell)
        self._length = length

        if isinstance(length, _QiVariableBase):
            self.add_associated_variable(length)
        elif isinstance(length, _QiCalcBase):
            for variable in length.contained_variables:
                self.add_associated_variable(variable)

        if isinstance(length, QiExpression):
            length._type_info.set_type(QiType.TIME, _TypeDefiningUse.WAIT_COMMAND)

    @property
    def length(self):
        return (
            self._length() if isinstance(self._length, QiCellProperty) else self._length
        )

    def _stringify(self) -> str:
        return f"Wait({self.cell}, {self._length})"


class AnyPlayCommand(QiCellCommand, QiTriggerCommand, ABC):
    """
    Base class of any Play commands.

    :param pulse: The pulse to play.
    """

    def __init__(self, cell, pulse: QiPulse):
        super().__init__(cell)
        self.pulse = pulse

        # default False; Set True for certain commands when unrolling a loop with TimingVariable == 1 cycle
        self._var_single_cycle_trigger = False

        for variable in self.pulse.variables:
            self.add_associated_variable(variable)

        # length of command might differ from pulse length
        self._length: float | _QiVariableBase | QiCellProperty = self.pulse.length

        self.trigger_index = 0

    @property
    def length(self):
        return (
            self._length() if isinstance(self._length, QiCellProperty) else self._length
        )

    @length.setter
    def length(self, value):
        self._length = value


class RecordingCommand(QiCellCommand):
    """Command generated by Recording()"""

    def __init__(
        self,
        cell: QiCell,
        save_to: str | _QiVariableBase | None,
        state_to: _QiVariableBase | None,
        length: int | float | QiCellProperty,
        offset: int | float | QiExpression,
        toggle_continuous: bool | None = None,
    ):
        super().__init__(cell)
        self.result_box = None
        self.var = None

        if (isinstance(length, QiExpression) and length.type == QiType.STATE) or (
            isinstance(offset, QiExpression) and offset.type == QiType.STATE
        ):
            raise RuntimeError("State variable can only be used at save_to parameter.")

        if isinstance(state_to, _QiVariableBase):
            state_to._type_info.set_type(
                QiType.STATE, _TypeDefiningUse.RECORDING_SAVE_TO
            )
            self.add_associated_variable(state_to)
            self.var = state_to

        self.save_to = save_to

        assert not isinstance(
            save_to, QiResult
        )  # support for QiResult as parameter was removed.
        if isinstance(save_to, _QiVariableBase):
            # TODO This should be deprecated and turned into new result variable
            # to handle I/Q values instead if necessary -> consistency
            if self.var is not None:
                raise RuntimeError("Cannot pass variable to state_to and save_to.")
            save_to._type_info.set_type(
                QiType.STATE, _TypeDefiningUse.RECORDING_SAVE_TO
            )
            self.add_associated_variable(save_to)
            self.var = save_to
        elif isinstance(save_to, str):
            self.result_box = cell.get_result_container(
                save_to
            )  # container might have been added to cell before
            self.save_to = save_to

        cell.add_recording_length(length)
        self._length = length
        if isinstance(self._length, QiExpression):
            self._length._type_info.set_type(
                QiType.TIME, _TypeDefiningUse.RECORDING_OFFSET_EXPRESSION
            )

        self._offset: QiExpression = QiExpression._from(offset)
        self._offset._type_info.set_type(
            QiType.TIME, _TypeDefiningUse.RECORDING_OFFSET_EXPRESSION
        )
        for var in self._offset.contained_variables:
            var._relevant_cells.add(cell)

        self.toggle_continuous = toggle_continuous

        self.follows_readout = False

        try:
            from .qi_jobs import QiJob

            cmd = QiJob.current().commands[-1]
            if isinstance(cmd, PlayReadoutCommand) and cmd.cell == self.cell:
                self.follows_readout = True
                cmd.recording = self
                cmd._associated_variable_set.update(self._associated_variable_set)
        except IndexError:
            pass

    @property
    def uses_state(self):
        return len(self._associated_variable_set) > 0

    @property
    def length(self):
        return (
            self._length() if isinstance(self._length, QiCellProperty) else self._length
        )

    @length.setter
    def length(self, value):
        self._length = value

    @property
    def offset(self):
        return (
            self._offset() if isinstance(self._offset, QiCellProperty) else self._offset
        )

    def _stringify_args(self) -> str:
        """Determines non-default args to explicitly stringify"""
        arg_strings = [str(self.cell), str(self._length)]

        if not (
            isinstance(self._offset, _QiConstValue) and self._offset._given_value == 0
        ):
            arg_strings.append(f"offset={self._offset}")

        if self.result_box is not None:
            arg_strings.append(f'save_to="{self.result_box.name}"')

        if self.var is not None:
            arg_strings.append(f"state_to={self.var}")

        if self.toggle_continuous is not None:
            arg_strings.append(f"toggleContinuous={self.toggle_continuous}")

        return ", ".join(arg_strings)

    def _stringify(self) -> str:
        return f"Recording({self._stringify_args()})"


class PlayCommand(AnyPlayCommand):
    """
    Command generated by Play()

    :param pulse: The pulse to play
    """

    def __init__(self, cell, pulse: QiPulse):
        super().__init__(cell, pulse)
        self.trigger_index = cell.add_pulse(pulse)

    def _stringify(self) -> str:
        return f"Play({self.cell}, {self.pulse._stringify()})"


class PlayFluxCommand(AnyPlayCommand):
    """Command generated by PlayFlux()"""

    def __init__(self, coupler: QiCoupler, pulse: QiPulse) -> None:
        super().__init__(coupler.associated_unit_cell, pulse)
        self._coupler = coupler
        self.trigger_index = coupler.add_pulse(pulse)

    def _stringify(self) -> str:
        return f"PlayFlux({self.coupler}, {self.pulse._stringify()})"

    @property
    def coupler(self) -> QiCoupler:
        return self._coupler


class PlayReadoutCommand(AnyPlayCommand):
    """Command generated by :meth:`PlayReadout`"""

    def __init__(self, cell, pulse) -> None:
        super().__init__(cell, pulse)
        self.recording: None | RecordingCommand = None
        self.trigger_index = cell.add_readout_pulse(pulse)

    @property
    def length(self):
        length = (
            self._length
            if not isinstance(self._length, QiCellProperty)
            else self._length()
        )

        # if Recording is defined and length is not defined by variable, compare both lengths
        if isinstance(self.recording, RecordingCommand) and not isinstance(
            self._length, _QiVariableBase
        ):
            return max(length, self.recording.length)
        return length

    @length.setter
    def length(self, value):
        self._length = value
        if isinstance(self.recording, RecordingCommand):
            self.recording.length = value

    @property
    def uses_state(self):
        return self.recording is not None and self.recording.uses_state

    def _stringify(self) -> str:
        return f"PlayReadout({self.cell}, {self.pulse._stringify()})"


class RotateFrameCommand(AnyPlayCommand):
    """Command generated by :meth:`RotateFrame`"""

    def __init__(self, cell, angle: float):
        # Negate phase because frame needs to be shifted in the opposite direction
        # than pulses -> want to shift the state on bloch sphere but shift the frame
        pulse = QiPulse(0, phase=-1 * angle)
        pulse.shift_phase = True  # Special property to make phase offset persistant
        super().__init__(cell, pulse)
        self.trigger_index = cell.add_pulse(pulse)
        self.length = util.conv_cycles_to_time(1)  # command needs exactly one cycle
        self.angle = angle

    def _stringify(self) -> str:
        return f"RotateFrame({self.cell}, {self.angle})"


class SyncCommand(QiCommand):
    """Command generated by :meth:`Sync`"""

    def __init__(self, cells: list[QiCell]):
        super().__init__()
        self._relevant_cells.update(cells)

    def accept(self, visitor: QiCommandVisitor, *args, **kwargs):
        return visitor.visit_sync_command(self, *args, **kwargs)

    def _stringify(self) -> str:
        cells = (
            str(cell) for cell in sorted(self._relevant_cells, key=lambda c: c.cell_id)
        )
        return f"Sync({', '.join(cells)})"


class StoreCommand(QiCellCommand):
    """Command generated by :meth:`Store`"""

    def __init__(self, cell, store_var: _QiVariableBase, save_to: QiResult):
        super().__init__(cell)
        self.store_var = store_var
        self.save_to = save_to

        self.add_associated_variable(store_var)

    def _stringify(self) -> str:
        return f"Store({self.cell}, {self.store_var}, {self.save_to})"


class AssignCommand(QiVariableCommand):
    """Command generated by :meth:`Assign`"""

    def __init__(self, dst: _QiVariableBase, value: QiExpression | int | float):
        from .qi_types import (
            _add_equal_constraints,
            _IllegalTypeReason,
            _TypeConstraintReasonQiCommand,
        )

        if not isinstance(dst, _QiVariableBase):
            raise TypeError("Target of Assign can only be a QiVariable.")

        super().__init__(dst)

        self._value = QiExpression._from(value)

        dst._type_info.add_illegal_type(QiType.STATE, _IllegalTypeReason.ASSIGN)
        _add_equal_constraints(
            QiType.NORMAL,
            _TypeConstraintReasonQiCommand(AssignCommand),
            self._value,
            dst,
        )
        _add_equal_constraints(
            QiType.TIME, _TypeConstraintReasonQiCommand(AssignCommand), self._value, dst
        )

        for variable in self.value.contained_variables:
            self.add_associated_variable(variable)

    @property
    def value(self):
        return self._value

    def accept(self, visitor: QiCommandVisitor, *args, **kwargs):
        super().accept(visitor, *args, **kwargs)
        return visitor.visit_assign_command(self, *args, **kwargs)

    def _stringify(self) -> str:
        return f"Assign({self.var}, {self._value})"


class DeclareCommand(QiVariableCommand):
    """Command generated by initialization of new QiVariable"""

    def __init__(self, dst: _QiVariableBase) -> None:
        super().__init__(var=dst)

    def accept(self, visitor: QiCommandVisitor, *args, **kwargs):
        super().accept(visitor, *args, **kwargs)
        return visitor.visit_declare_command(self, *args, **kwargs)

    def _stringify(self) -> str:
        return f"v{self.var.str_id} =  {self.var}"


class AsmCommand(QiCommand):
    def __init__(self, cells: QiCell, instr: SequencerInstruction, cycles: int):
        super().__init__()
        self._relevant_cells.add(cells)
        self.asm_instruction = instr
        self.cycles = cycles

    def accept(self, visitor: QiCommandVisitor, *args, **kwargs):
        return visitor.visit_asm_command(self, *args, **kwargs)

    def _stringify(self) -> str:
        return f"ASM({self.asm_instruction.get_riscv_instruction()})"


class MemStoreCommand(QiCommand):
    def __init__(self, cell: QiCell, addr: int, value):
        super().__init__()
        self._relevant_cells.add(cell)
        self.addr = addr
        self.value = value

    def accept(self, visitor: QiCommandVisitor, *args, **kwargs):
        return visitor.visit_mem_store_command(self, *args, **kwargs)

    def _stringify(self):
        cell_str = ", ".join([f"{x}" for x in self._relevant_cells])
        return f"MemStore({cell_str}, {self.addr}, {self.value})"


class IfCommand(QiCommand):
    """
    An If Command adds conditional logic to the program.

    :param condition: The condition to evaluate
    :param body: The code to execute if the condition is true
    :param else_stmt: The code to execute if the condition is false
    """

    def __init__(
        self,
        condition: QiCondition,
        body: list[QiCommand] | None = None,
        else_stmt: list[QiCommand] | None = None,
    ):
        super().__init__()
        self.condition = condition
        self.body = body or []
        self._else_body = else_stmt or []

        for variable in condition.contained_variables:
            self.add_associated_variable(variable)

    def accept(self, visitor, *args, **kwargs):
        return visitor.visit_if(self, *args, **kwargs)

    def is_followed_by_else(self) -> bool:
        return len(self._else_body) != 0

    def add_else_body(self, else_body):
        self._else_body = else_body.copy()

    @property
    def else_body(self) -> list[QiCommand]:
        return self._else_body

    def _stringify(self) -> str:
        return f"If({self.condition})"

    def __iter__(self):
        return iter(self.body)


class ForRangeCommand(QiCommand):
    def __init__(
        self,
        var: _QiVariableBase,
        start: QiExpression,
        end: QiExpression,
        step: QiExpression,
        body: list[QiCommand],
    ):
        super().__init__()

        self.var = var
        self.start = start
        self.end = end
        self.step = step

        self.add_associated_variable(var)

        if isinstance(start, _QiVariableBase):
            self.add_associated_variable(start)

            if start.id == var.id:
                raise RuntimeError("Loop variable can not be used as start value")

        if isinstance(end, _QiVariableBase):
            self.add_associated_variable(end)

            if end.id == var.id:
                raise RuntimeError("Loop variable can not be used as end value")

        self.body = body

    def accept(self, visitor, *args, **kwargs):
        return visitor.visit_for_range(self, *args, **kwargs)

    @property
    def is_step_positive(self) -> bool:
        return self.step > 0

    def _stringify(self) -> str:
        return f"ForRange({self.var}, {self.start}, {self.end}, {self.step})"

    @property
    def body(self) -> list[QiCommand]:
        return self._body

    @body.setter
    def body(self, commands: list[QiCommand]):
        self._body = commands
        check_variable = QiVarInForRange(self.var)
        self.accept(check_variable)

    def __iter__(self):
        return iter(self.body)


class ParallelCommand(QiCommand):
    def __init__(
        self,
        body: list[QiCommand] | None = None,
        entries: list[list[QiCommand]] | None = None,
    ):
        super().__init__()
        self.body = body or []
        self._entries = entries or []

    def append_entry(self, entry: list[QiCommand]):
        self.entries.append(entry)

        containing_cells = QiCMContainedCellVisitor()
        for command in entry:
            if not isinstance(
                command,
                (
                    PlayCommand,
                    RecordingCommand,
                    RotateFrameCommand,
                    PlayReadoutCommand,
                    PlayFluxCommand,
                    WaitCommand,
                    DigitalTriggerCommand,
                ),
            ):
                raise RuntimeError(
                    f"Type {command.__class__.__name__} not allowed inside Parallel()"
                )
            if (
                isinstance(command, (RecordingCommand, PlayReadoutCommand))
                and command.uses_state
            ):
                raise RuntimeError("Can not save to state variable inside Parallel")

            try:
                if hasattr(command, "length") and isinstance(
                    command.length, _QiVariableBase
                ):
                    self._associated_variable_set.add(command.length)
            except KeyError:
                pass  # length was QiCellProperty
            command.accept(containing_cells)

        self._relevant_cells.update(containing_cells.contained_cells)

    @property
    def entries(self) -> list[list[QiCommand]]:
        return self._entries

    @dataclass
    class CmdTuple:
        cmd: QiCommand
        start: int
        end: int
        choke_cmd: bool = False

    @dataclass
    class TimeSlot:
        cmd_tuples: list[ParallelCommand.CmdTuple]
        start: int
        end: int
        duration: float = 0.0

    def _clear_wait_commands(self, cmd_tuples: list[CmdTuple]) -> list[CmdTuple]:
        """Clears WaitCommand commands from cmd_tuples, if any trigger command is also in cmd_tuples"""
        has_trigger_cmd = any(
            isinstance(cmd_tuple.cmd, AnyPlayCommand) for cmd_tuple in cmd_tuples
        )

        return [
            cmd_tuple
            for cmd_tuple in cmd_tuples
            if isinstance(cmd_tuple.cmd, AnyPlayCommand) or not has_trigger_cmd
        ]

    def _clear_choke_commands(self, cmd_tuples: list[CmdTuple]):
        """Clears choke commands, if at the same slot another Play or Readout command is present."""

        contains_play = False
        contains_readout = False

        for cmd_tuple in cmd_tuples:
            if isinstance(cmd_tuple.cmd, PlayCommand) and cmd_tuple.choke_cmd is False:
                contains_play = True
            elif (
                isinstance(cmd_tuple.cmd, PlayReadoutCommand)
                and cmd_tuple.choke_cmd is False
            ):
                contains_readout = True

        if not contains_play and not contains_readout:
            return cmd_tuples

        cleared_tuples = []

        for cmd_tuple in cmd_tuples:
            # if play command is present skip choke command for play
            if isinstance(cmd_tuple.cmd, PlayCommand):
                if cmd_tuple.choke_cmd and contains_play:
                    continue

            # if PlayReadout command is present skip choke command for PlayReadout
            elif isinstance(cmd_tuple.cmd, PlayReadoutCommand):
                if cmd_tuple.choke_cmd and contains_readout:
                    continue

            cleared_tuples.append(cmd_tuple)

        return cleared_tuples

    def _create_time_slots(
        self, annotated_bodies: list[list[CmdTuple]], max_end: int
    ) -> list[ParallelCommand.TimeSlot]:
        time_slots: list[ParallelCommand.TimeSlot] = []
        for start in range(max_end):
            time_slot = ParallelCommand.TimeSlot([], start, start)

            # find tuples with start time == start
            for cmd_list in annotated_bodies:
                for cmd_tuple in cmd_list:
                    if cmd_tuple.start == start:
                        time_slot.cmd_tuples.append(cmd_tuple)
                        time_slot.end = max(cmd_tuple.end, time_slot.end)
                        cmd_list.remove(cmd_tuple)
                        break  # next cmd_list

            # next start value, if nothing was found
            if len(time_slot.cmd_tuples) == 0:
                continue

            time_slot.cmd_tuples = self._clear_wait_commands(time_slot.cmd_tuples)
            time_slot.cmd_tuples = self._clear_choke_commands(time_slot.cmd_tuples)

            # Add Wait command, if previous end value < start
            try:
                prev_time_slot = time_slots[-1]
                if prev_time_slot.end < start:
                    length = util.conv_cycles_to_time(start - prev_time_slot.end)
                    new_wait = self.CmdTuple(
                        WaitCommand(next(iter(self._relevant_cells)), length),
                        start=prev_time_slot.end,
                        end=start,
                    )
                    time_slots.append(
                        self.TimeSlot([new_wait], prev_time_slot.end, start)
                    )
            except IndexError:
                pass

            # Adjust previous end time, if previous.end > start
            try:
                prev_time_slot = time_slots[-1]
                prev_time_slot.end = min(prev_time_slot.end, start)
            except IndexError:
                pass

            time_slots.append(time_slot)

        # Add final wait, if previous.end != max_end
        try:
            prev_time_slot = time_slots[-1]
            if prev_time_slot.end < max_end:
                length = util.conv_cycles_to_time(max_end - prev_time_slot.end)
                new_wait = self.CmdTuple(
                    WaitCommand(next(iter(self._relevant_cells)), length),
                    start=prev_time_slot.end,
                    end=max_end,
                )
                time_slots.append(
                    self.TimeSlot([new_wait], prev_time_slot.end, max_end)
                )
        except IndexError:
            pass

        # calculate duration of time slot
        for slot in time_slots:
            slot.duration = util.conv_cycles_to_time(slot.end - slot.start)

        return time_slots

    def _generate_command_body(self, cell, sequencer) -> list[ParallelCommand.TimeSlot]:
        """Combines the parallel sequences to one command body."""

        parallel_bodies: list[list[ParallelCommand.CmdTuple]] = []

        max_end = 0

        # Generate annotated list of commands with start and end cycle
        for cmd_list in self.entries:
            commands: list[ParallelCommand.CmdTuple] = []
            start: int = 0
            end: int = 0
            for cmd in cmd_list:
                var_pulse = False

                if cell not in cmd._relevant_cells:
                    continue  # skip commands for other cells

                if isinstance(cmd.length, _QiVariableBase):
                    reg = sequencer.get_var_register(cmd.length)

                    if not reg.valid or reg.value is None:
                        raise RuntimeError(
                            "Variable inside parallel not initialised or invalidated"
                        )

                    length = reg.value

                    if isinstance(cmd, (PlayCommand, PlayReadoutCommand)):
                        var_pulse = True
                else:
                    length = util.conv_time_to_cycles(cmd.length, "ceil")

                if length == 0:
                    continue  # skip commands with length 0

                if isinstance(cmd, RecordingCommand) or (
                    isinstance(cmd, PlayReadoutCommand)
                    and isinstance(cmd.recording, RecordingCommand)
                ):
                    end += length + util.conv_time_to_cycles(
                        sequencer.recording_delay, "ceil"
                    )
                else:
                    end += length

                cmd_duration = self.CmdTuple(cmd, start, end)
                commands.append(cmd_duration)

                if var_pulse:
                    # Add parallel choke command after current command, if variable length is used
                    parallel_choke = [self.CmdTuple(cmd, end, end + 1, choke_cmd=True)]
                    parallel_bodies.append(parallel_choke)

                    max_end = max(end + 1, max_end)  # +1 to account for choke command
                else:
                    max_end = max(end, max_end)

                start = end

            parallel_bodies.append(commands)

        return self._create_time_slots(parallel_bodies, max_end)

    def accept(self, visitor, *args, **kwargs):
        return visitor.visit_parallel(self, *args, **kwargs)

    def _stringify(self) -> str:
        return "Parallel"
