"""
The :mod:`scadnano` Python module is a library for describing synthetic DNA nanostructures
(e.g., DNA origami).
Installation instructions are at the
`GitHub repository <https://github.com/UC-Davis-molecular-computing/scadnano-python-package>`_.

This module is used to write Python scripts outputting ``*.dna`` files readable
by `scadnano <https://web.cs.ucdavis.edu/~doty/scadnano/>`_, a web application useful for displaying
and manually editing these structures.
The purpose of this module is to help automate some of the task of creating DNA designs,
as well as making large-scale changes to them that are easier to describe programmatically than
to do by hand in scadnano.

If you find scadnano useful in a scientific project, please cite its associated paper:

 | scadnano: A browser-based, scriptable tool for designing DNA nanostructures.
 | David Doty, Benjamin L Lee, and Tristan Stérin.
 | DNA 2020: *Proceedings of the 26th International Conference on DNA Computing and Molecular Programming*
 | [ `paper <https://arxiv.org/abs/2005.11841>`_ | `BibTeX <https://web.cs.ucdavis.edu/~doty/papers/scadnano.bib>`_ ]

This library uses typing hints from the Python typing library.
(https://docs.python.org/3/library/typing.html)
Each function and method indicate intended types of the parameters.
However, due to Python's design, these types are not enforced at runtime.
It is suggested to use a static analysis tool such as that provided by an IDE such as PyCharm
(https://www.jetbrains.com/pycharm/)
to see warnings when the typing rules are violated. 
Such warnings probably indicate an erroneous usage.

Most of the classes in this module are Python dataclasses
(https://docs.python.org/3/library/dataclasses.html)
whose fields show up in the documentation.
Their types are listed in parentheses after the name of the class;
for example :any:`Color` has ``int`` fields :py:data:`Color.r`, :py:data:`Color.g`, :py:data:`Color.b`.
In general it is safe to read these fields directly, but not to write to them directly.
Setter methods (named ``set_<fieldname>``) are provided for fields where it makes sense to set it to another
value than it had originally.
However, due to Python naming conventions for dataclass fields and property setters,
it is not straightforward to enforce that the fields cannot be written, 
so the user must take care not to set them.
"""

# needed to use forward annotations: https://docs.python.org/3/whatsnew/3.7.html#whatsnew37-pep563
# commented out for now to support Python 3.6, which does not support this feature
# from __future__ import annotations

import dataclasses
from abc import abstractmethod, ABC
import json
import enum
import itertools
import re
import copy
from dataclasses import dataclass, field, InitVar, replace
from typing import Tuple, List, Set, Dict, Union, Optional, FrozenSet, Type, TypeVar, Generic
from collections import defaultdict, OrderedDict, Counter
import sys
import os.path

# Don't really understand why, but an explicit import solves the issue described here
# https://stackoverflow.com/a/39131141
# solves the build problems: https://github.com/UC-Davis-molecular-computing/scadnano-python-package/actions/runs/125490116
try:
    from ._version import __version__
except ImportError:
    # this is so scadnano.py file works without _version.py being present, in case user downloads it
    __version__ = "0.9.9"

StrandLabel = TypeVar('StrandLabel')
DomainLabel = TypeVar('DomainLabel')


def _pairwise(iterable):
    """s -> (s0,s1), (s1,s2), (s2, s3), ..."""
    a, b = itertools.tee(iterable)
    next(b, None)
    return zip(a, b)


##############################################################################
# JSON serialization
# There are external libraries to handle JSON
# in Python, but I want this to be a simple, single-file library, so we just
# implement what we need below.


class _JSONSerializable(ABC):

    @abstractmethod
    def to_json_serializable(self, suppress_indent: bool = True):
        raise NotImplementedError()


def _json_encode(obj: _JSONSerializable, suppress_indent: bool = True) -> str:
    encoder = _SuppressableIndentEncoder if suppress_indent else json.JSONEncoder
    serializable = obj.to_json_serializable(suppress_indent=suppress_indent)
    return json.dumps(serializable, cls=encoder, indent=2)


class NoIndent:
    # Value wrapper. Placing a value in this will stop it from being indented when converting to JSON
    # using _SuppressableIndentEncoder

    def __init__(self, value):
        self.value = value


class _SuppressableIndentEncoder(json.JSONEncoder):
    def __init__(self, *args, **kwargs):
        self.unique_id = 0
        super(_SuppressableIndentEncoder, self).__init__(*args, **kwargs)
        self.kwargs = dict(kwargs)
        del self.kwargs['indent']
        self._replacement_map = {}

    def default(self, obj):
        if isinstance(obj, NoIndent):
            # key = uuid.uuid1().hex # this caused problems with Brython.
            key = self.unique_id
            self.unique_id += 1
            self._replacement_map[key] = json.dumps(obj.value, **self.kwargs)
            return "@@%s@@" % (key,)
        else:
            return super().default(obj)

    def encode(self, obj):
        result = super().encode(obj)
        for k, v in self._replacement_map.items():
            result = result.replace('"@@%s@@"' % (k,), v)
        return result


#
# END JSON serialization
##############################################################################


##############################################################################
# Colors
# As with JSON serialization, there are external libraries to handle colors
# in Python, but I want this to be a simple, single-file library, so we just
# implement what we need below.

@dataclass
class Color(_JSONSerializable):
    r: Optional[int] = None
    """
    Red component: 0-255.
    
    Optional if :py:data:`Color.hex` is given."""

    g: Optional[int] = None
    """Green component: 0-255.
    
    Optional if :py:data:`Color.hex` is given."""

    b: Optional[int] = None
    """Blue component: 0-255.
    
    Optional if :py:data:`Color.hex` is given."""

    hex_string: InitVar[str] = None
    """Hex color preceded by # sign, e.g., "#ff0000" is red.
    
    Optional if :py:data:`Color.r`, :py:data:`Color.g`, :py:data:`Color.b` are all given."""

    def __post_init__(self, hex_string):
        if hex_string is None:
            assert (self.r is not None and self.g is not None and self.b is not None)
        else:
            assert (self.r is None and self.g is None and self.b is None)
            hex_string = hex_string.lstrip('#')
            self.r = int(hex_string[0:2], 16)
            self.g = int(hex_string[2:4], 16)
            self.b = int(hex_string[4:6], 16)

    def to_json_serializable(self, suppress_indent: bool = True):
        # Return object representing this Color that is JSON serializable.
        # return NoIndent(self.__dict__) if suppress_indent else self.__dict__
        return f'#{self.r:02x}{self.g:02x}{self.b:02x}'

    def to_cadnano_v2_int_hex(self):
        return int(f'{self.r:02x}{self.g:02x}{self.b:02x}', 16)

    @classmethod
    def from_cadnano_v2_int_hex(cls, hex_int):
        hex_str = "0x{:06x}".format(hex_int)
        return Color(hex_string=hex_str[2:])


# https://medium.com/@rjurney/kellys-22-colours-of-maximum-contrast-58edb70c90d1
_kelly_colors = [  # 'F2F3F4', #almost white so it's no good
    '222222', 'F3C300', '875692', 'F38400', 'A1CAF1', 'BE0032', 'C2B280', '848482',
    '008856', 'E68FAC', '0067A5', 'F99379', '604E97', 'F6A600', 'B3446C', 'DCD300', '882D17',
    '8DB600', '654522', 'E25822', '2B3D26']


class ColorCycler:
    """
    Calling ``next(color_cycler)`` on a ColorCycler named ``color_cycler``
    returns a the next :any:`Color` from a fixed size list,
    cycling after reaching the end of the list.

    To choose new colors, set ``color_cycler.colors`` to a new list of :any:`Color`'s.
    """

    # These are copied from cadnano:
    # https://github.com/sdouglas/cadnano2/blob/master/views/styles.py#L97
    _colors: List[Color] = [Color(50, 184, 108),
                            Color(204, 0, 0),
                            Color(247, 67, 8),
                            Color(247, 147, 30),
                            Color(170, 170, 0),
                            Color(87, 187, 0),
                            Color(0, 114, 0),
                            Color(3, 182, 162),
                            # Color(23, 0, 222), # don't like this because it looks too much like scaffold
                            Color(50, 0, 150),  # this one is better contrast with scaffold
                            Color(184, 5, 108),
                            Color(51, 51, 51),
                            Color(115, 0, 222),
                            Color(136, 136, 136)]
    """List of colors to cycle through."""

    # _colors = [Color(hex_string=kelly_color) for kelly_color in _kelly_colors]
    # """List of colors to cycle through."""

    def __init__(self):
        self._current_color_idx = 0
        # random order
        order = [3, 11, 0, 12, 8, 1, 10, 6, 5, 9, 4, 7, 2]
        # order = range(len(self._colors))
        colors_shuffled: List[Color] = list(self._colors)
        for i, color in zip(order, self._colors):
            colors_shuffled[i] = color
        self._colors: List[Color] = colors_shuffled

    def __iter__(self):
        # need to make ColorCycler an iterator
        return self

    def __next__(self):
        color = self.current_color()
        self._current_color_idx = (self._current_color_idx + 1) % len(self._colors)
        return color

    def current_color(self) -> Color:
        return self._colors[self._current_color_idx]

    def __hash__(self):
        return hash(self.current_color())

    def __eq__(self, other):
        if not isinstance(other, ColorCycler):
            return False
        return self._current_color_idx == other._current_color_idx

    def __str__(self):
        repr(self)

    def __repr__(self):
        return f'ColorCycler({self.current_color()})'

    @property
    def colors(self):
        """The colors that are cycled through when calling ``next()`` on some :any:`ColorCycler`."""
        return list(self._colors)

    @colors.setter
    def colors(self, newcolors):
        self._colors = newcolors
        self._current_color_idx = 0


default_scaffold_color = Color(0, 102, 204)
"""Default color for scaffold strand(s)."""

default_strand_color = Color(0, 0, 0)
"""Default color for non-scaffold strand(s)."""


#
# END Colors
##############################################################################


@enum.unique
class Grid(str, enum.Enum):
    """
    Represents default patterns for laying out helices in the side view.
    Each :any:`Grid` except :py:data:`Grid.none` has an interpretation of a "grid position",
    which is a 2D integer coordinate (`h`, `v`).
    (scadnano also allows a 3rd coordinate (`h`, `v`, `b`) specifying a "base offset" at which to position
    the start of the :any:`Helix`, which is not relevant for the side view but will eventually be
    supported to adjust the main view.)
    """

    square = "square"
    """
    Square lattice. 
    Increasing `h` moves right and increasing `v` moves down. 
    (i.e., "computer screen coordinates" rather than Cartesian coordinates where positive `y` is up.)
    """

    hex = "hex"
    """
    Hexagonal lattice. Uses the *"odd-r horizontal layout"* coordinate system described here: 
    https://www.redblobgames.com/grids/hexagons/. 
    Incrementing `v` moves down and to the right if `h` is even, 
    and moves down and to the left if `h` is odd.
    """

    honeycomb = "honeycomb"
    """
    Honeycomb lattice. This consists of all the hex lattice positions except where 
    honeycomb lattice disallows grid positions (`h`, `v`) with 
    `v` even and `h` a multiple of 3 or
    `v` odd and `h` = 1 + a multiple of 3.  
    
    However, we use the same convention as cadnano for encoding hex coordinates see `misc/cadnano-format-specs/v2.txt`.
    That convention is different from simply excluding coordinates from the hex lattice.

    """

    none = "none"
    """No fixed grid."""


# convenience names for users
square = Grid.square
hexagonal = Grid.hex  # should not use identifier "hex" because that's a Python built-in function
honeycomb = Grid.honeycomb

##########################################################################
# constants

default_idt_scale = "25nm"
default_idt_purification = "STD"


def default_major_tick_distance(grid: Grid) -> int:
    return 7 if grid in (Grid.hex, Grid.honeycomb) else 8


default_pitch: float = 0.0
default_roll: float = 0.0
default_yaw: float = 0.0

# XXX: code below related to SVG positions is not currently needed in the scripting library,
# but I want to make sure these conventions are documented somewhere, so they are just commented out for now.
#
# base_width_svg: float = 10.0
# """Width of a single base in the SVG main view of scadnano."""
#
# base_height_svg: float = 10.0
# """Height of a single base in the SVG main view of scadnano."""
#
# distance_between_helices_nm: float = 2.5
# """Distance between centers of helices in nanometers.
# See :py:data:`distance_between_helices_svg` for explanation of this value."""
#
# base_width_nm: float = 0.34
# """Width of a single DNA base in nanometers."""
#
# distance_between_helices_svg: float = base_width_svg * distance_between_helices_nm / base_width_nm
# """Distance between tops of two consecutive helices (using default positioning rules).
#
# This is set to (:const:`base_width_svg` * 2.5/0.34) based on the following calculation,
# to attempt to make the DNA structure appear to scale in 2D drawings:
# The width of one base pair of double-stranded DNA bp is 0.34 nm. In a DNA origami,
# AFM images let us estimate that the average distance between adjacent double helices is 2.5 nm.
# (A DNA double-helix is only 2 nm wide, but the helices electrostatically repel each other so the spacing
# in a DNA origami or an other DNA nanostructure with many parallel DNA helices---e.g., single-stranded tile
# lattices---is larger than 2 nm.)
# Thus the distance between the helices is 2.5/0.34 ~ 7.5 times the width of a single DNA base.
# """

DNA_base_wildcard: str = '?'
"""Symbol to insert when a DNA sequence has been assigned to a strand through complementarity, but
some regions of the strand are not bound to the strand that was just assigned. Also used in case the
DNA sequence assigned to a strand is too short; the sequence is padded with :any:`DNA_base_wildcard` to 
make its length the same as the length of the strand."""


def _rotate_string(string: str, rotation: int):
    rotation = rotation % len(string)
    return string[rotation:] + string[:rotation]


class M13Variant(enum.Enum):
    """Variants of M13mp18 viral genome. "Standard" variant is p7249. Other variants are longer."""

    p7249 = "p7249"
    """"Standard" variant of M13mp18; 7249 bases long, available from, for example
    
    https://www.neb.com/products/n4040-m13mp18-single-stranded-dna
    
    http://www.bayoubiolabs.com/biochemicat/vectors/pUCM13/
    
    https://www.tilibit.com/collections/scaffold-dna/products/single-stranded-scaffold-dna-type-p7249
    """

    p7560 = "p7560"
    """Variant of M13mp18 that is 7560 bases long. Available from, for example
    
    https://www.tilibit.com/collections/scaffold-dna/products/single-stranded-scaffold-dna-type-p7560
    """

    p8064 = "p8064"
    """Variant of M13mp18 that is 8064 bases long. Available from, for example
    
    https://www.tilibit.com/collections/scaffold-dna/products/single-stranded-scaffold-dna-type-p8064
    """


def m13(rotation: int = 5587, variant: M13Variant = M13Variant.p7249):
    """
    The M13mp18 DNA sequence (commonly called simply M13).
    
    By default, starts from cyclic rotation 5587 
    (with 0-based indexing;  commonly this is called rotation 5588, which assumes that indexing begins at 1), 
    as defined in
    `GenBank <https://www.ncbi.nlm.nih.gov/nuccore/X02513.1>`_.
    
    By default, returns the "standard" variant of consisting of 7249 bases, sold by companies such as  
    `New England Biolabs <https://www.neb.com/~/media/nebus/page%20images/tools%20and%20resources/interactive%20tools/dna%20sequences%20and%20maps/m13mp18_map.pdf>`_
    and
    `Tilibit <https://cdn.shopify.com/s/files/1/1299/5863/files/Product_Sheet_single-stranded_scaffold_DNA_type_7249_M1-10.pdf?14656642867652657391>`_.
    
    The actual M13 DNA strand itself is circular, 
    so assigning this sequence to the scaffold :any:`Strand` in a :any:`DNADesign`
    means that the "5' end" of the scaffold :any:`Strand` 
    (which is a fiction since the actual circular DNA strand has no endpoint) 
    will have the sequence starting at position 5587 starting at the displayed 5' in scadnano,
    assigned until the displayed 3' end. 
    Assuming the displayed scaffold :any:`Strand` has length :math:`n < 7249`, then a loopout of length 
    :math:`7249 - n` consisting of the undisplayed bases will be present in the actual DNA structure.
    For a more detailed discussion of why this particular rotation of M13 is chosen,
    see 
    `Supplementary Note S8 <http://www.dna.caltech.edu/Papers/DNAorigami-supp1.linux.pdf>`_ 
    in
    [`Folding DNA to create nanoscale shapes and patterns. Paul W. K. Rothemund, Nature 440:297-302 (2006) <http://www.nature.com/nature/journal/v440/n7082/abs/nature04586.html>`_].
    
    :param rotation: rotation of circular strand. Valid values are 0 through length-1.
    :param variant: variant of M13 strand to use
    :return: M13 strand sequence
    """  # noqa (suppress PEP warning)
    seq = _m13_variants[variant]
    return _rotate_string(seq, rotation)


_7249 = re.sub(r'\s', '', '''

GCTTAATTTTGCTAATTCTTTGCCTTGCCTGTATGATTTATTGGATGTT
''')

_7560 = re.sub(r'\s', '', '''

''')

_8064 = re.sub(r'\s', '', '''

''')

_m13_variants = {
    M13Variant.p7249: _7249,
    M13Variant.p7560: _7560,
    M13Variant.p8064: _8064,
}

##################
# keys

# DNADesign keys
version_key = 'version'
grid_key = 'grid'
major_tick_distance_key = 'major_tick_distance'
major_ticks_key = 'major_ticks'
helices_key = 'helices'
strands_key = 'strands'
scaffold_key = 'scaffold'
helices_view_order_key = 'helices_view_order'
is_origami_key = 'is_origami'
design_modifications_key = 'modifications_in_design'
geometry_key = 'geometry'

# Geometry keys
rise_per_base_pair_key = 'rise_per_base_pair'
legacy_rise_per_base_pair_keys = ['z_step']
helix_radius_key = 'helix_radius'
bases_per_turn_key = 'bases_per_turn'
minor_groove_angle_key = 'minor_groove_angle'
inter_helix_gap_key = 'inter_helix_gap'

# Helix keys
idx_on_helix_key = 'idx'
max_offset_key = 'max_offset'
min_offset_key = 'min_offset'
grid_position_key = 'grid_position'
position_key = 'position'
legacy_position_keys = ['origin']

# Position keys
position_x_key = 'x'
position_y_key = 'y'
position_z_key = 'z'
pitch_key = 'pitch'
roll_key = 'roll'
yaw_key = 'yaw'
position_origin_key = 'origin'

# Strand keys
color_key = 'color'
dna_sequence_key = 'sequence'
legacy_dna_sequence_keys = ['dna_sequence']  # support legacy names for these ideas
domains_key = 'domains'
legacy_domains_keys = ['substrands']  # support legacy names for these ideas
idt_key = 'idt'
is_scaffold_key = 'is_scaffold'
modification_5p_key = '5prime_modification'
modification_3p_key = '3prime_modification'
modifications_int_key = 'internal_modifications'
strand_label_key = 'label'

# Domain keys
helix_idx_key = 'helix'
forward_key = 'forward'
legacy_forward_keys = ['right']  # support legacy names for these ideas
start_key = 'start'
end_key = 'end'
deletions_key = 'deletions'
insertions_key = 'insertions'
domain_label_key = 'label'

# Loopout keys
loopout_key = 'loopout'

# Modification keys
mod_location_key = 'location'
mod_display_text_key = 'display_text'
mod_id_key = 'id'
mod_idt_text_key = 'idt_text'
mod_font_size_key = 'font_size'
mod_display_connector_key = 'display_connector'
mod_allowed_bases_key = 'allowed_bases'


# end keys
##################

# end constants
##########################################################################


##########################################################################
# modification abstract base classes


@dataclass(frozen=True, eq=True)
class Modification(_JSONSerializable):
    """Base class of modifications (to DNA sequences, e.g., biotin or Cy3).
    Use :any:`Modification3Prime`, :any:`Modification5Prime`, or :any:`ModificationInternal`
    to instantiate."""

    display_text: str
    """Short text to display in the web interface as an "icon"
    visually representing the modification, e.g., ``'B'`` for biotin or ``'Cy3'`` for Cy3."""

    id: str = "WARNING: no id assigned to modification"
    """Short representation as a string; used to write in :any:`Strand` json representation,
    while the full description of the modification is written under a global key in the :any:`DNADesign`."""

    idt_text: Optional[str] = None
    """IDT text string specifying this modification (e.g., '/5Biosg/' for 5' biotin). optional"""

    def to_json_serializable(self, suppress_indent: bool = True):
        ret = {mod_display_text_key: self.display_text}
        if self.idt_text is not None:
            ret[mod_idt_text_key] = self.idt_text
            ret[mod_display_connector_key] = False  # type: ignore
        return ret

    @staticmethod
    def from_json(json_map: dict) -> 'Modification':  # remove quotes when Python 3.6 support dropped
        location = json_map[mod_location_key]
        if location == "5'":
            return Modification5Prime.from_json(json_map)
        elif location == "3'":
            return Modification3Prime.from_json(json_map)
        elif location == "internal":
            return ModificationInternal.from_json(json_map)
        else:
            raise IllegalDNADesignError(f'unknown Modification location "{location}"')


@dataclass(frozen=True, eq=True)
class Modification5Prime(Modification):
    """5' modification of DNA sequence, e.g., biotin or Cy3."""

    def to_json_serializable(self, suppress_indent: bool = True):
        ret = super().to_json_serializable(suppress_indent)
        ret[mod_location_key] = "5'"
        return ret

    @staticmethod
    def from_json(json_map: dict) -> 'Modification5Prime':  # remove quotes when Python 3.6 support dropped
        display_text = json_map[mod_display_text_key]
        location = json_map[mod_location_key]
        assert location == "5'"
        idt_text = json_map.get(mod_idt_text_key)
        return Modification5Prime(display_text=display_text, idt_text=idt_text)


@dataclass(frozen=True, eq=True)
class Modification3Prime(Modification):
    """3' modification of DNA sequence, e.g., biotin or Cy3."""

    def to_json_serializable(self, suppress_indent: bool = True):
        ret = super().to_json_serializable(suppress_indent)
        ret[mod_location_key] = "3'"
        return ret

    @staticmethod
    def from_json(json_map: dict) -> 'Modification3Prime':  # remove quotes when Python 3.6 support dropped
        display_text = json_map[mod_display_text_key]
        location = json_map[mod_location_key]
        assert location == "3'"
        idt_text = json_map.get(mod_idt_text_key)
        return Modification3Prime(display_text=display_text, idt_text=idt_text)


@dataclass(frozen=True, eq=True)
class ModificationInternal(Modification):
    """Internal modification of DNA sequence, e.g., biotin or Cy3."""

    allowed_bases: Optional[FrozenSet[str]] = None
    """If None, then this is an internal modification that goes between bases. 
    If instead it is a list of bases, then this is an internal modification that attaches to a base,
    and this lists the allowed bases for this internal modification to be placed at. 
    For example, internal biotins for IDT must be at a T. If any base is allowed, it should be
    ``['A','C','G','T']``."""

    def to_json_serializable(self, suppress_indent: bool = True):
        ret = super().to_json_serializable(suppress_indent)
        ret[mod_location_key] = "internal"
        if self.allowed_bases is not None:
            ret[mod_allowed_bases_key] = NoIndent(
                list(self.allowed_bases)) if suppress_indent else list(self.allowed_bases)
        return ret

    @staticmethod
    def from_json(json_map: dict) -> 'ModificationInternal':  # remove quotes when Python 3.6 support dropped
        display_text = json_map[mod_display_text_key]
        location = json_map[mod_location_key]
        assert location == "internal"
        idt_text = json_map.get(mod_idt_text_key)
        allowed_bases_list = json_map.get(mod_allowed_bases_key)
        allowed_bases = frozenset(allowed_bases_list) if allowed_bases_list is not None else None
        return ModificationInternal(display_text=display_text, idt_text=idt_text, allowed_bases=allowed_bases)


# end modification abstract base classes
##########################################################################

@dataclass
class Position3D(_JSONSerializable):
    """
    Position (x,y,z) in 3D space.
    """

    x: float = 0
    """x-coordinate of position. 
    Increasing `x` moves right in the main view and into the screen in the side view."""

    y: float = 0
    """y-coordinate of position.
    Increasing `y` moves down in the side and main views, i.e., "screen coordinates".
    (though this can be inverted to Cartesian coordinates, to agree with codenano)"""

    z: float = 0
    """z-coordinate of position.
    Increasing `z` moves right in the side view and out of the screen in the main view."""

    def to_json_serializable(self, suppress_indent: bool = True):
        dct = self.__dict__
        # return NoIndent(dct) if suppress_indent else dct
        return dct

    @staticmethod
    def from_json(json_map: dict) -> 'Position3D':  # remove quotes when Python 3.6 support dropped
        if position_origin_key in json_map:
            origin = json_map[position_origin_key]
            x = origin[position_x_key]
            y = origin[position_y_key]
            z = origin[position_z_key]
        else:
            x = json_map[position_x_key]
            y = json_map[position_y_key]
            z = json_map[position_z_key]
        return Position3D(x=x, y=y, z=z)


def in_browser() -> bool:
    """Test if this code is running in the browser.

    Checks for existence of package "pyodide" used in pyodide. If present it is assumed the code is
    running in the browser."""
    try:
        import pyodide  # type: ignore
        return True
    except ImportError:
        return False


@dataclass
class Helix(_JSONSerializable):
    """
    Represents a "helix" where :any:`Domain`'s could go. Technically a :any:`Helix` can contain no
    :any:`Domain`'s. More commonly, some partial regions of it may have only 1 or 0 :any:`Domain`'s.
    So it is best thought of as a "potential" double-helix.

    It has a 1-dimensional integer coordinate system given by "offsets", integers between
    :py:data:`Helix.min_offset` (inclusive) and :py:data:`Helix.max_offset` (exclusive).
    At any valid offset for this :any:`Helix`, at most two :any:`Domain`'s may share that offset
    on this :any:`Helix`, and if there are exactly two, then one must have
    :py:data:`Domain.forward` = ``true`` and the other must have
    :py:data:`Domain.forward` = ``false``.

    Once part of a :any:`DNADesign`, a :any:`Helix` has an index (accessible  via :py:meth:`Helix.idx`
    once the :any:`DNADesign` is created)
    representing its order in the list of all :any:`Helix`'s. This index is how a :any:`Domain` is
    associated to the :any:`Helix` via the integer index :any:`Domain.helix`.
    """

    max_offset: int = None  # type: ignore
    """Maximum offset (exclusive) of :any:`Domain` that can be drawn on this :any:`Helix`. 
    If unspecified, it is calculated when the :any:`DNADesign` is instantiated as 
    the largest :any:`Domain.end` offset of any :any:`Domain` in the design.
    """

    min_offset: int = 0
    """Minimum offset (inclusive) of :any:`Domain` that can be drawn on this :any:`Helix`. 
    If unspecified, it is set to 0.
    """

    major_tick_distance: int = -1
    """If positive, overrides :any:`DNADesign.major_tick_distance`."""

    major_ticks: List[int] = None  # type: ignore
    """If not ``None``, overrides :any:`DNADesign.major_tick_distance` and :any:`Helix.major_tick_distance`
    to specify a list of offsets at which to put major ticks."""

    grid_position: Tuple[int, int] = None  # type: ignore
    """`(h,v)` position of this helix in the side view grid,
    if :const:`Grid.square`, :const:`Grid.hex` , or :const:`Grid.honeycomb` is used
    in the :any:`DNADesign` containing this helix.
    `h` and `v` are in units of "helices": incrementing `h` moves right one helix in the grid
    and incrementing `v` moves down one helix in the grid. 
    In the case of the hexagonal lattice, 
    The convention is that incrementing `v` moves down and to the right if h is even, 
    and moves down and to the left if `h` is odd.
    This is the "odd-r horizontal layout" coordinate system here: 
    https://www.redblobgames.com/grids/hexagons/)
    However, the default y position in the main view for helices does not otherwise depend on grid_position.
    The default is to list the y-coordinates in order by helix idx.
    
    Default is `h` = 0, `v` = index of :any:`Helix` in :py:data:`DNADesign.helices`.
    
    In the case of the honeycomb lattice, we use the same convention as cadnano for encoding hex coordinates,
    see `misc/cadnano-format-specs/v2.txt`.
    That convention is different from simply excluding coordinates from the hex lattice.
    """

    position: Position3D = None  # type: ignore
    """Position (x,y,z) of this :any:`Helix` in 3D space.
    
    Must be None if :py:data:`Helix.grid_position` is specified."""

    pitch: float = 0
    """Angle in the main view plane; 0 means pointing to the right (min_offset on left, max_offset on right).
    Rotation is clockwise in the main view.
    See https://en.wikipedia.org/wiki/Aircraft_principal_axes
    Units are degrees."""

    roll: float = 0
    """Angle around the center of the helix; 0 means pointing straight up in the side view.
    Rotation is clockwise in the side view.
    See https://en.wikipedia.org/wiki/Aircraft_principal_axes
    Units are degrees."""

    yaw: float = 0
    """Third angle for orientation besides :py:data:`Helix.pitch` and :py:data:`Helix.roll`.
    Not visually displayed in scadnano, but here to support more general 3D applications.
    See https://en.wikipedia.org/wiki/Aircraft_principal_axes
    Units are degrees."""

    idx: int = None  # type: ignore
    """Index of this :any:`Helix`.
    
    Optional if no other :any:`Helix` specifies a value for *idx*.
    Default is the order of the :any:`Helix` is listed in constructor for :any:`DNADesign`."""

    # for optimization; list of domains on that Helix
    _domains: List['Domain'] = field(default_factory=list)

    def __post_init__(self):
        if self.grid_position is not None and self.position is not None:
            raise IllegalDNADesignError('exactly one of grid_position or position must be specified, '
                                        'but both are specified')
        if self.major_ticks is not None and self.max_offset is not None and self.min_offset is not None:
            for major_tick in self.major_ticks:
                if major_tick > self.max_offset - self.min_offset:
                    raise IllegalDNADesignError(f'major tick {major_tick} in list {self.major_ticks} is '
                                                f'outside the range of available offsets since max_offset = '
                                                f'{self.max_offset}')

    def to_json_serializable(self, suppress_indent: bool = True):
        dct = dict()

        # if we have major ticks or position, it's harder to read Helix on one line,
        # so don't wrap it in NoIndent, but still wrap longer sub-objects in them
        use_no_indent: bool = not (self.major_ticks is not None or self.position is not None)

        if self.min_offset != 0:
            dct[min_offset_key] = self.min_offset

        dct[max_offset_key] = self.max_offset

        if self.position is None:
            dct[grid_position_key] = NoIndent(
                self.grid_position) if suppress_indent and not use_no_indent else self.grid_position
        else:
            pos = self.position.to_json_serializable(suppress_indent)
            dct[position_key] = NoIndent(pos) if suppress_indent and not use_no_indent else pos

        if not _is_close(self.pitch, default_pitch):
            dct[pitch_key] = self.pitch
        if not _is_close(self.roll, default_roll):
            dct[roll_key] = self.roll
        if not _is_close(self.yaw, default_yaw):
            dct[yaw_key] = self.yaw

        if self.major_tick_distance is not None and self.major_tick_distance > 0:
            dct[major_tick_distance_key] = self.major_tick_distance

        if self.major_ticks is not None:
            ticks = self.major_ticks
            dct[major_ticks_key] = NoIndent(ticks) if suppress_indent and not use_no_indent else ticks

        dct[idx_on_helix_key] = self.idx

        return NoIndent(dct) if suppress_indent and use_no_indent else dct

    def default_grid_position(self):
        return 0, self.idx

    def calculate_major_ticks(self, default_major_tick_distance_: int):
        """
        Calculates full list of major tick marks, whether using `default_major_tick_distance` (from
        :any:`DNADesign`), :py:data:`Helix.major_tick_distance`, or :py:data:`Helix.major_ticks`.
        They are used in reverse order to determine precedence. (e.g., :py:data:`Helix.major_ticks`
        overrides :py:data:`Helix.major_tick_distance`, which overrides
        `default_major_tick_distance` from :any:`DNADesign`.
        """
        if self.major_ticks is not None:
            return self.major_ticks
        distance = default_major_tick_distance_ if self.major_tick_distance <= 0 else self.major_tick_distance
        return list(range(self.min_offset, self.max_offset + 1, distance))

    @staticmethod
    def from_json(json_map: dict) -> 'Helix':  # remove quotes when Python 3.6 support dropped
        grid_position = None
        if grid_position_key in json_map:
            gp_list = json_map[grid_position_key]
            if len(gp_list) == 3:
                gp_list = gp_list[:2]
            if len(gp_list) != 2:
                raise IllegalDNADesignError("list of grid_position coordinates must be length 2, "
                                            f"but this is the list: {gp_list}")
            grid_position = tuple(gp_list)

        major_tick_distance = json_map.get(major_tick_distance_key)
        major_ticks = json_map.get(major_ticks_key)
        min_offset = json_map.get(min_offset_key)
        max_offset = json_map.get(max_offset_key)
        idx = json_map.get(idx_on_helix_key)

        position_map = optional_field(None, json_map, position_key, *legacy_position_keys)
        position = Position3D.from_json(position_map) if position_map is not None else None

        pitch = json_map.get(pitch_key, default_pitch)
        roll = json_map.get(roll_key, default_roll)
        yaw = json_map.get(yaw_key, default_yaw)

        return Helix(
            major_tick_distance=major_tick_distance,
            major_ticks=major_ticks,
            grid_position=grid_position,
            min_offset=min_offset,
            max_offset=max_offset,
            position=position,
            pitch=pitch,
            roll=roll,
            yaw=yaw,
            idx=idx,
        )

    @property
    def domains(self):
        """
        Return :any:`Domain`'s on this :any:`Helix`.
        Assigned when a :any:`DNADesign` is created using this :any:`Helix`.

        :return: :any:`Domain`'s on this helix
        """
        return self._domains


def _is_close(x1: float, x2: float):
    return abs(x1 - x2) < 0.00000001


@dataclass
class Domain(_JSONSerializable, Generic[DomainLabel]):
    """
    A maximal portion of a :any:`Strand` that is continguous on a single :any:`Helix`.
    A :any:`Strand` contains a list of :any:`Domain`'s (and also potentially :any:`Loopout`'s).
    """

    helix: int
    """index of the :any:`Helix` on which this :any:`Domain` resides."""

    forward: bool
    """Whether the strand "points" forward (i.e., its 3' end has a larger offset than its 5' end).
    If :any:`Domain.forward` is ``True``, then 
    :any:`Domain.start` is the 5' end of the :any:`Domain` and 
    :any:`Domain.end` is the 3' end of the :any:`Domain`.
    If :any:`Domain.forward` is ``False``, these roles are reversed."""

    start: int
    """
    The smallest offset position of any base on this Domain
    (3' end if :any:`Domain.forward` = ``False``,
    5' end if :any:`Domain.forward` = ``True``).
    """

    end: int
    """
    1 plus the largest offset position of any base on this Domain
    (5' end if :any:`Domain.forward` = ``False``,
    3' end if :any:`Domain.forward` = ``True``).
    Note that the set of base offsets occupied by this Domain is {start, start+1, ..., end-1},
    i.e., inclusive for :py:data:`Strand.start` but exclusive for :py:data:`Strand.end`,
    the same convention used in Python for slices of lists and strings.
    (e.g., :samp:`"abcdef"[1:3] == "bc"`)
    
    Some methods (such as :py:meth:`Domain.dna_sequence_in`) use the convention of being inclusive on 
    both ends and are marked with the word "INCLUSIVE".
    (Such a convention is easier to reason about when there are insertions and deletions.)
    """

    deletions: List[int] = field(default_factory=list)
    """List of positions of deletions on this Domain."""

    insertions: List[Tuple[int, int]] = field(default_factory=list)
    """List of (position,num_insertions) pairs on this Domain.
    
    This is the number of *extra* bases in addition to the base already at this position. 
    The total number of bases at this offset is num_insertions+1."""

    label: DomainLabel = None
    """Generic "label" object to associate to this :any:`Domain`.

    Useful for associating extra information with the :any:`Domain` that will be serialized, for example,
    for DNA sequence design. It must be an object (e.g., a dict or primitive type such as str or int) 
    that is naturally JSON serializable. (Calling ``json.dumps`` on the object should succeed without
    having to specify a custom encoder.)
    """

    # not serialized; for efficiency
    # remove quotes when Python 3.6 support dropped
    _parent_strand: 'Strand' = field(init=False, repr=False, compare=False, default=None)

    def __post_init__(self):
        self._check_start_end()

    def __repr__(self):
        rep = (f'Domain(helix={self.helix}'
               f', forward={self.forward}'
               f', start={self.start}'
               f', end={self.end}') + \
              (f', deletions={self.deletions}' if len(self.deletions) > 0 else '') + \
              (f', insertions={self.insertions}' if len(self.insertions) > 0 else '') + \
              ')'
        return rep

    def __str__(self):
        return repr(self)

    def strand(self) -> 'Strand':  # remove quotes when Python 3.6 support dropped
        return self._parent_strand

    def set_label(self, label: StrandLabel):
        """Sets label of this :any:`Domain`."""
        self.label = label

    def _check_start_end(self):
        if self.start >= self.end:
            raise StrandError(self._parent_strand,
                              f'start = {self.start} must be less than end = {self.end}')

    def to_json_serializable(self, suppress_indent: bool = True):
        dct = OrderedDict()
        dct[helix_idx_key] = self.helix
        dct[forward_key] = self.forward
        dct[start_key] = self.start
        dct[end_key] = self.end
        if len(self.deletions) > 0:
            dct[deletions_key] = self.deletions
        if len(self.insertions) > 0:
            dct[insertions_key] = self.insertions
        if self.label is not None:
            dct[domain_label_key] = self.label
        return NoIndent(dct) if suppress_indent else dct

    @staticmethod
    def is_loopout() -> bool:
        """Indicates if this is a :any:`Loopout` (always false)
        Useful when object could be either :any:`Loopout` or :any:`Domain`."""
        return False

    @staticmethod
    def is_domain() -> bool:
        """Indicates if this is a :any:`Domain` (always true)
        Useful when object could be either :any:`Loopout` or :any:`Domain`."""
        return True

    def set_start(self, new_start: int):
        self.start = new_start
        self._check_start_end()

    def set_end(self, new_end: int):
        self.end = new_end
        self._check_start_end()

    def offset_5p(self) -> int:
        """5' offset of this :any:`Domain`, INCLUSIVE."""
        if self.forward:
            return self.start
        else:
            return self.end - 1
        # return self.start if self.forward else self.end - 1

    def offset_3p(self) -> int:
        """3' offset of this :any:`Domain`, INCLUSIVE."""
        return self.end - 1 if self.forward else self.start

    def _num_insertions(self) -> int:
        # total number of insertions in this Domain
        return sum(insertion[1] for insertion in self.insertions)

    def contains_offset(self, offset: int) -> bool:
        """Indicates if `offset` is the offset of a base on this :any:`Domain`.

        Note that offsets refer to visual portions of the displayed grid for the Helix.
        If for example, this Domain starts at position 0 and ends at 10, and it has 5 deletions,
        then it contains the offset 7 even though there is no base 7 positions from the start."""
        return self.start <= offset < self.end

    def __len__(self):
        """Same as :meth:`Domain.dna_length`.

        See also :meth:`Domain.visual_length`."""
        return self.dna_length()

    def dna_length(self) -> int:
        """Number of bases in this Domain."""
        return self.end - self.start - len(self.deletions) + self._num_insertions()

    def dna_length_in(self, left, right) -> int:
        """Number of bases in this Domain between `left` and `right` (INCLUSIVE)."""
        if not left <= right + 1:
            raise ValueError(f'left = {left} and right = {right} but we should have left <= right + 1')
        if not self.start <= left:
            raise ValueError(f'left = {left} should be at least self.start = {self.start}')
        if not right < self.end:
            raise ValueError(f'right = {right} should be at most self.end - 1 = {self.end - 1}')
        num_deletions = sum(1 for offset in self.deletions if left <= offset <= right)
        num_insertions = sum(length for (offset, length) in self.insertions if left <= offset <= right)
        return (right - left + 1) - num_deletions + num_insertions

    def visual_length(self) -> int:
        """Distance between :any:`Domain.start` offset and :any:`Domain.end` offset.

        This can be more or less than the :meth:`Domain.dna_length` due to insertions and deletions."""
        return self.end - self.start

    def dna_sequence(self) -> Optional[str]:
        """Return DNA sequence of this Domain, or ``None`` if no DNA sequence has been assigned
        to this :any:`Domain`'s :any:`Strand`."""
        return self.dna_sequence_in(self.start, self.end - 1)

    def dna_sequence_in(self, offset_left: int, offset_right: int) -> Optional[str]:
        """Return DNA sequence of this Domain in the interval of offsets given by
        [`offset_left`, `offset_right`], INCLUSIVE, or ``None`` if no DNA sequence has been assigned
        to this :any:`Domain`'s :any:`Strand`.

        WARNING: This is inclusive on both ends,
        unlike other parts of this API where the right endpoint is exclusive.
        This is to make the notion well-defined when one of the endpoints is on an offset with a
        deletion or insertion."""
        strand_seq = self._parent_strand.dna_sequence
        if strand_seq is None:
            return None

        # if on a deletion, move inward until we are off of it
        while offset_left in self.deletions:
            offset_left += 1
        while offset_right in self.deletions:
            offset_right -= 1

        if offset_left > offset_right:
            return ''
        if offset_left >= self.end:
            return ''
        if offset_right < 0:
            return ''

        str_idx_left = self.domain_offset_to_strand_dna_idx(offset_left, self.forward)
        str_idx_right = self.domain_offset_to_strand_dna_idx(offset_right, not self.forward)
        if not self.forward:  # these will be out of order if strand is left
            str_idx_left, str_idx_right = str_idx_right, str_idx_left
        subseq = strand_seq[str_idx_left:str_idx_right + 1]
        return subseq

    def get_seq_start_idx(self) -> int:
        """Starting DNA subsequence index for first base of this :any:`Domain` on its
        Parent :any:`Strand`'s DNA sequence."""
        domains = self._parent_strand.domains
        # index of self in parent strand's list of domains
        self_domain_idx = domains.index(self)
        # index of self's position within the DNA sequence of parent strand
        self_seq_idx_start = sum(prev_domain.dna_length()
                                 for prev_domain in domains[:self_domain_idx])
        return self_seq_idx_start

    def domain_offset_to_strand_dna_idx(self, offset: int, offset_closer_to_5p: bool) -> int:
        """ Convert from offset on this :any:`Domain`'s :any:`Helix`
        to string index on the parent :any:`Strand`'s DNA sequence.

        If `offset_closer_to_5p` is ``True``, (this only matters if `offset` contains an insertion)
        then the only leftmost string index corresponding to this offset is included,
        otherwise up to the rightmost string index (including all insertions) is included."""
        if offset in self.deletions:
            raise ValueError(f'offset {offset} illegally contains a deletion from {self.deletions}')

        # length adjustment for insertions depends on whether this is a left or right offset
        len_adjust = self._net_ins_del_length_increase_from_5p_to(offset, offset_closer_to_5p)

        # get string index assuming this Domain is first on Strand
        if self.forward:
            offset += len_adjust  # account for insertions and deletions
            domain_str_idx = offset - self.start
        else:
            # account for insertions and deletions
            offset -= len_adjust  # account for insertions and deletions
            domain_str_idx = self.end - 1 - offset

        # correct for existence of previous Domains on this Strand
        return domain_str_idx + self.get_seq_start_idx()

    def _net_ins_del_length_increase_from_5p_to(self, offset_edge: int, offset_closer_to_5p: bool) -> int:
        """Net number of insertions from 5'/3' end to offset_edge,
        INCLUSIVE on 5'/3' end, EXCLUSIVE on offset_edge.

        Set `five_p` ``= False`` to test from 3' end to `offset_edge`."""
        length_increase = 0
        for deletion in self.deletions:
            if self._between_5p_and_offset(deletion, offset_edge):
                length_increase -= 1
        for (insertion_offset, insertion_length) in self.insertions:
            if self._between_5p_and_offset(insertion_offset, offset_edge):
                length_increase += insertion_length
        # special case for when offset_edge is an endpoint closer to the 3' end,
        # we add its extra insertions also in this case
        if not offset_closer_to_5p:
            insertion_map: Dict[int, int] = dict(self.insertions)
            if offset_edge in insertion_map:
                insertion_length = insertion_map[offset_edge]
                length_increase += insertion_length
        return length_increase

    def _between_5p_and_offset(self, offset_to_test: int, offset_edge: int) -> bool:
        return ((self.forward and self.start <= offset_to_test < offset_edge) or
                (not self.forward and offset_edge < offset_to_test < self.end))

    # def _between_3p_and_offset(self, offset_to_test: int, offset_edge: int) -> bool:
    #     return ((self.direction == Direction.left and self.start <= offset_to_test < offset_edge) or
    #             (self.direction == Direction.forward and offset_edge < offset_to_test < self.end))

    # The type hint 'Domain' must be in quotes since Domain is not yet defined.
    # This is a "forward reference": https://www.python.org/dev/peps/pep-0484/#forward-references
    # remove quotes when Python 3.6 support dropped
    # def overlaps(self, other: Domain[DomainLabel]) -> bool:
    def overlaps(self, other: 'Domain') -> bool:
        r"""Indicates if this :any:`Domain`'s set of offsets (the set
        :math:`\{x \in \mathbb{N} \mid`
        ``self.start``
        :math:`\leq x \leq`
        ``self.end``
        :math:`\}`)
        has nonempty intersection with those of `other`,
        and they appear on the same helix,
        and they point in opposite directions."""  # noqa (suppress PEP warning)
        return (self.helix == other.helix and
                self.forward == (not other.forward) and
                self.compute_overlap(other)[0] >= 0)

    # remove quotes when Python 3.6 support dropped
    # def overlaps_illegally(self, other: Domain[DomainLabel]):
    def overlaps_illegally(self, other: 'Domain'):
        r"""Indicates if this :any:`Domain`'s set of offsets (the set
        :math:`\{x \in \mathbb{N} \mid`
        ``self.start``
        :math:`\leq x \leq`
        ``self.end``
        :math:`\}`)
        has nonempty intersection with those of `other`,
        and they appear on the same helix,
        and they point in the same direction."""  # noqa (suppress PEP warning)
        return (self.helix == other.helix and
                self.forward == other.forward and
                self.compute_overlap(other)[0] >= 0)

    # remove quotes when Python 3.6 support dropped
    # def compute_overlap(self, other: Domain[DomainLabel]) -> Tuple[int, int]:
    def compute_overlap(self, other: 'Domain') -> Tuple[int, int]:
        """Return [left,right) offset indicating overlap between this Domain and `other`.

        Return ``(-1,-1)`` if they do not overlap (different helices, or non-overlapping regions
        of the same helix)."""
        overlap_start = max(self.start, other.start)
        overlap_end = min(self.end, other.end)
        if overlap_start >= overlap_end:  # overlap is empty
            return -1, -1
        return overlap_start, overlap_end

    def insertion_offsets(self) -> List[int]:
        """Return offsets of insertions (but not their lengths)."""
        return [ins_off for (ins_off, _) in self.insertions]

    @staticmethod
    def from_json(json_map):
        helix = mandatory_field(Domain, json_map, helix_idx_key)
        forward = mandatory_field(Domain, json_map, forward_key, *legacy_forward_keys)
        start = mandatory_field(Domain, json_map, start_key)
        end = mandatory_field(Domain, json_map, end_key)
        deletions = json_map.get(deletions_key, [])
        insertions = list(map(tuple, json_map.get(insertions_key, [])))
        label = json_map.get(domain_label_key)
        return Domain(
            helix=helix,
            forward=forward,
            start=start,
            end=end,
            deletions=deletions,
            insertions=insertions,
            label=label,
        )


'''
    var forward = util.get_value(json_map, constants.forward_key, name);
    var helix = util.get_value(json_map, constants.helix_idx_key, name);
    var start = util.get_value(json_map, constants.start_key, name);
    var end = util.get_value(json_map, constants.end_key, name);
//    List<int> deletions =
//        json_map.containsKey(constants.deletions_key) ? List<int>.from(json_map[constants.deletions_key]) : [];
//    List<Tuple2<int, int>> insertions =
//        json_map.containsKey(constants.insertions_key) ? parse_json_insertions(json_map[constants.insertions_key]) : [];
    var deletions = List<int>.from(util.get_value_with_default(json_map, constants.deletions_key, []));
    var insertions =
        parse_json_insertions(util.get_value_with_default(json_map, constants.insertions_key, []));
'''


@dataclass
class Loopout(_JSONSerializable):
    """Represents a single-stranded loopout on a :any:`Strand`.

    One could think of a :any:`Loopout` as a type of :any:`Domain`, but none of the fields of
    :any:`Domain` make sense for :any:`Loopout`, so they are not related to each other in the type
    hierarchy. It is interpreted that a :any:`Loopout` is a single-stranded region bridging two
    :any:`Domain`'s that are connected to :any:`Helix`'s, or if it occurs on the end of a :any:`Strand`,
    then it is a single-stranded extension. It is illegal for two consecutive :any:`Domain`'s to both
    be :any:`Loopout`'s, and for a :any:`Strand` to have only one element of :any:`Strand.domains`
    that is a :any:`Loopout`.

    Loopout has only a single field :py:data:`Loopout.length` that specifies the length of the loopout.

    For example, one use of a loopout is to describe a hairpin (a.k.a.,
    `stem-loop <https://en.wikipedia.org/wiki/Stem-loop>`_).
    The following creates a :any:`Strand` that represents a hairpin with a stem length of 10 and a loop
    length of 5.

    .. code-block:: Python

        import scadnano as sc

        domain_f = sc.Domain(helix=0, forward=True, start=0, end=10)
        loop = sc.Loopout(length=5)
        domain_r = sc.Domain(helix=0, forward=False, start=0, end=10)
        hairpin = sc.Strand([domain_f, loop, domain_r])
    """

    length: int
    """Length (in DNA bases) of this Loopout."""

    # not serialized; for efficiency
    # remove quotes when Python 3.6 support dropped
    _parent_strand: 'Strand' = field(init=False, repr=False, compare=False, default=None)

    def to_json_serializable(self, suppress_indent: bool = True):
        dct = {loopout_key: self.length}
        return NoIndent(dct)

    def __repr__(self):
        return f'Loopout({self.length})'

    def __str__(self):
        return repr(self)

    @staticmethod
    def is_loopout() -> bool:
        """Indicates if this is a :any:`Loopout` (always true).
        Useful when object could be either :any:`Loopout` or :any:`Domain`."""
        return True

    @staticmethod
    def is_domain() -> bool:
        """Indicates if this is a :any:`Domain` (always false)
        Useful when object could be either :any:`Loopout` or :any:`Domain`."""
        return False

    def __len__(self):
        """Same as :any:`Loopout.dna_length`"""
        return self.dna_length()

    def dna_length(self) -> int:
        """Length of this :any:`Loopout`; same as field :py:data:`Loopout.length`."""
        return self.length

    def dna_sequence(self) -> Optional[str]:
        """Return DNA sequence of this :any:`Loopout`, or ``None`` if no DNA sequence has been assigned
        to the :any:`Strand` of this :any:`Loopout`."""
        strand_seq = self._parent_strand.dna_sequence
        if strand_seq is None:
            return None

        str_idx_left = self.get_seq_start_idx()
        str_idx_right = str_idx_left + self.length  # EXCLUSIVE (unlike similar code for Domain)
        subseq = strand_seq[str_idx_left:str_idx_right]
        return subseq

    def get_seq_start_idx(self) -> int:
        """Starting DNA subsequence index for first base of this :any:`Loopout` on its
        :any:`Strand`'s DNA sequence."""
        domains = self._parent_strand.domains
        # index of self in parent strand's list of domains
        self_domain_idx = domains.index(self)
        # index of self's position within the DNA sequence of parent strand
        self_seq_idx_start = sum(prev_domain.dna_length()
                                 for prev_domain in domains[:self_domain_idx])
        return self_seq_idx_start

    @staticmethod
    def from_json(json_map) -> 'Loopout':  # remove quotes when Python 3.6 support dropped
        # XXX: this should never fail since we detect whether to call this from_json by the presence
        # of a length key in json_map
        length_str = mandatory_field(Loopout, json_map, loopout_key)
        length = int(length_str)
        return Loopout(length=length)


_wctable = str.maketrans('ACGTacgt', 'TGCAtgca')


def wc(seq: str) -> str:
    """Return reverse Watson-Crick complement of `seq`."""
    return seq.translate(_wctable)[::-1]


@dataclass
class IDTFields(_JSONSerializable):
    """Data required when ordering DNA strands from the synthesis company
    `IDT (Integrated DNA Technologies) <https://www.idtdna.com/>`_.
    This data is used when automatically generating files used to order DNA from IDT."""

    name: str
    """Name of the strand (first field in IDT bulk input: https://www.idtdna.com/site/order/oligoentry).
    
    Non-optional field.
    """

    scale: str = default_idt_scale
    """Synthesis scale at which to synthesize the strand (third field in IDT bulk input:
    https://www.idtdna.com/site/order/oligoentry).
    Choices supplied by IDT at the time this was written: 
    ``"25nm"``, ``"100nm"``, ``"250nm"``, ``"1um"``, ``"5um"``, 
    ``"10um"``, ``"4nmU"``, ``"20nmU"``, ``"PU"``, ``"25nmS"``.
    
    Optional field.
    """

    purification: str = default_idt_purification
    """Purification options (fourth field in IDT bulk input:
    https://www.idtdna.com/site/order/oligoentry). 
    Choices supplied by IDT at the time this was written: 
    ``"STD"``, ``"PAGE"``, ``"HPLC"``, ``"IEHPLC"``, ``"RNASE"``, ``"DUALHPLC"``, ``"PAGEHPLC"``.
    
    Optional field.
    """

    plate: Optional[str] = None
    """Name of plate in case this strand will be ordered on a 96-well or 384-well plate.
    
    Optional field, but non-optional if :py:data:`IDTField.well` is not ``None``.
    """

    well: Optional[str] = None
    """Well position on plate in case this strand will be ordered on a 96-well or 384-well plate.
    
    Optional field, but non-optional if :py:data:`IDTField.plate` is not ``None``.
    """

    def __post_init__(self):
        _check_idt_string_not_none_or_empty(self.name, 'name')
        _check_idt_string_not_none_or_empty(self.scale, 'scale')
        _check_idt_string_not_none_or_empty(self.purification, 'purification')
        if self.plate is None and self.well is not None:
            raise IllegalDNADesignError(f'IDTFields.plate cannot be None if IDTFields.well is not None\n'
                                        f'IDTFields.well = {self.well}')
        if self.plate is not None and self.well is None:
            raise IllegalDNADesignError(f'IDTFields.well cannot be None if IDTFields.plate is not None\n'
                                        f'IDTFields.plate = {self.plate}')

    def to_json_serializable(self, suppress_indent: bool = True):
        dct = self.__dict__
        if self.plate is None:
            del dct['plate']
        if self.well is None:
            del dct['well']
        return NoIndent(dct)


def _check_idt_string_not_none_or_empty(value: str, field_name: str):
    if value is None:
        raise IllegalDNADesignError(f'field {field_name} in IDTFields cannot be None')
    if len(value) == 0:
        raise IllegalDNADesignError(f'field {field_name} in IDTFields cannot be empty')


class StrandBuilder:
    """
    Represents a :any:`Strand` that is being built in an existing :any:`DNADesign`.

    This is an intermediate object created when using "literal" chained method building by calling
    :py:meth:`DNADesign.strand`, for example

    .. code-block:: Python

        design.strand(0, 0).to(10).cross(1).to(5).with_modification_5p(mod.biotin_5p).as_scaffold()

    :any:`StrandBuilder` should generally not be created directly or manipulated in ways other than the kind
    of method chaining described above, or else errors could result.
    """

    # remove quotes when Python 3.6 support dropped
    def __init__(self, design: 'DNADesign', helix: int, offset: int):
        self.design: DNADesign = design
        self.current_helix: int = helix
        self.current_offset: int = offset
        self.loopout_length: Optional[int] = None
        self.strand: Optional[Strand] = None
        self.just_moved_to_helix: bool = True
        self.last_domain: Optional[Domain[DomainLabel]] = None

    # remove quotes when Python 3.6 support dropped
    def cross(self, helix: int, offset: int = None) -> 'StrandBuilder':
        """
        Add crossover. Must be followed by call to :py:meth:`StrandBuilder.to` to have any effect.

        :param helix: :any:`Helix` to crossover to
        :param offset: new offset on `helix`. If not specified, defaults to current offset.
            (i.e., a "vertical" crossover)
        :return: self
        """
        self.last_domain = None
        self.current_helix = helix
        if offset is not None:
            self.current_offset = offset
        return self

    # remove quotes when Python 3.6 support dropped
    def loopout(self, helix: int, length: int, offset: int = None) -> 'StrandBuilder':
        """
        Like :py:meth:`StrandBuilder.cross`, but creates a :any:`Loopout` instead of a crossover.

        :param helix: :any:`Helix` to crossover to
        :param length: length of :any:`Loopout` to add
        :param offset: new offset on `helix`. If not specified, defaults to current offset.
            (i.e., a "vertical" crossover)
        :return: self
        """
        self.loopout_length = length
        return self.cross(helix, offset)

    def to(self, offset: int) -> 'StrandBuilder':  # remove quotes when Python 3.6 support dropped
        """
        Extends this :any:`StrandBuilder` on the current helix to offset `offset`,
        which adds a new :any:`Domain` to the :any:`Strand` being built.

        This updates the underlying :any:`DNADesign` with a new :any:`Domain`,
        and if :py:meth:`StrandBuilder.loopout` was last called on this :any:`StrandBuilder`,
        also a new :any:`Loopout`.

        If two instances of :py:meth:`StrandBuilder.to` are chained together, this creates two domains
        on the same helix. The two offsets must move in the same direction. In other words, if the starting
        offset is ``s``, and we call ``.to(o1).to(o2)``, then either ``s < o1 < o2`` or ``o2 < o1 < s``
        must be true.

        To simply change the current offset after calling :py:meth:`StrandBuilder.to`, without creating
        a new Domain, call :py:meth:`StrandBuilder.update_to` instead.

        :param offset: new offset to extend to. If less than current offset,
            the new :any:`Domain` is reverse, otherwise it is forward.
        :return: self
        """
        if self.last_domain and ((self.last_domain.forward and offset < self.current_offset) or (
                not self.last_domain.forward and offset > self.current_offset)):
            raise IllegalDNADesignError('offsets must be monotonic '
                                        '(strictly increasing or strictly decreasing) '
                                        'when calling to() twice in a row')

        if offset > self.current_offset:
            forward = True
            start = self.current_offset
            end = offset
        elif offset < self.current_offset:
            forward = False
            start = offset
            end = self.current_offset
        else:
            raise IllegalDNADesignError(f'offset {offset} cannot be equal to current offset')

        domain = Domain(helix=self.current_helix, forward=forward, start=start, end=end)
        self.last_domain = domain
        if self.strand:
            if self.loopout_length is not None:
                self.design.append_domain(self.strand, Loopout(self.loopout_length))
            self.design.append_domain(self.strand, domain)
            self.loopout_length = None
        else:
            self.strand = Strand(domains=[domain])
            self.design.add_strand(self.strand)

        self.current_offset = offset

        return self

    def update_to(self, offset: int) -> 'StrandBuilder':  # remove quotes when Python 3.6 support dropped
        """
        Like :py:meth:`StrandBuilder.to`, but changes the current offset without creating
        a new :any:`Domain`. So unlike :py:meth:`StrandBuilder.to`, several consecutive calls to
        :py:meth:`StrandBuilder.update_to` are equivalent to only making the final call.

        If :py:meth:`StrandBuilder.cross` or :py:meth:`StrandBuilder.loopout` was just called,
        then :py:meth:`StrandBuilder.to` and :py:meth:`StrandBuilder.update_to` have the same effect.

        :param offset: new offset to extend to. If less than offset of the last call to
            :py:meth:`StrandBuilder.cross` or :py:meth:`StrandBuilder.loopout`,
            the new :any:`Domain` is reverse, otherwise it is forward.
        :return: self
        """
        if not self.last_domain:
            return self.to(offset)

        domain = self.last_domain
        if (self.last_domain.forward and offset < self.current_offset) or (
                not self.last_domain.forward and offset > self.current_offset):
            raise IllegalDNADesignError(f'when calling ')

        if domain.forward:
            domain.set_end(offset)
        else:
            domain.set_start(offset)

        self.current_offset = offset

        return self

    def as_scaffold(self) -> 'StrandBuilder':  # remove quotes when Python 3.6 support dropped
        """
        Makes Strand being built a scaffold.

        :return: self
        """
        self.strand.set_scaffold(True)
        return self

    # remove quotes when Python 3.6 support dropped
    def with_modification_5p(self, mod: Modification5Prime) -> 'StrandBuilder':
        """
        Sets Strand being built to have given 5' modification.

        :param mod: 5' modification
        :return: self
        """
        self.strand.set_modification_5p(mod)
        return self

    # remove quotes when Python 3.6 support dropped
    def with_modification_3p(self, mod: Modification3Prime) -> 'StrandBuilder':
        """
        Sets Strand being built to have given 3' modification.

        :param mod: 3' modification
        :return: self
        """
        self.strand.set_modification_3p(mod)
        return self

    # remove quotes when Python 3.6 support dropped
    def with_modification_internal(self, idx: int, mod: ModificationInternal,
                                   warn_on_no_dna: bool) -> 'StrandBuilder':
        """
        Sets Strand being built to have given internal modification.

        :param idx: idx along DNA sequence of internal modification
        :param mod: internal modification
        :param warn_on_no_dna: whether to print warning to screen if DNA has not been assigned
        :return: self
        """
        self.strand.set_modification_internal(idx, mod, warn_on_no_dna)
        return self

    # remove quotes when Python 3.6 support dropped
    def with_color(self, color: Color) -> 'StrandBuilder':
        """
        Sets Strand being built to have given color.

        :param color: color to set for Strand
        :return: self
        """
        self.strand.set_color(color)
        return self

    # remove quotes when Python 3.6 support dropped
    def with_sequence(self, sequence: str, assign_complement: bool = True) -> 'StrandBuilder':
        """
        Assigns `sequence` as DNA sequence of the :any:`Strand` being built.
        This should be done after the :any:`Strand`'s structure is done being built, e.g.,

        .. code-block:: Python

            design.strand(0, 0).to(10).cross(1).to(5).with_sequence('AAAAAAAAAACGCGC')

        :param sequence: the DNA sequence to assign to the :any:`Strand`
        :param assign_complement: whether to automatically assign the complement to existing :any:`Strand`'s
            bound to this :any:`Strand`. This has the same meaning as the parameter `assign_complement` in
            :py:meth:`DNADesign.assign_dna`.
        :return: self
        """
        self.design.assign_dna(strand=self.strand, sequence=sequence, assign_complement=assign_complement)
        return self

    # remove quotes when Python 3.6 support dropped
    def with_domain_sequence(self, sequence: str, assign_complement: bool = True) -> 'StrandBuilder':
        """
        Assigns `sequence` as DNA sequence of the most recently created :any:`Domain` in
        the :any:`Strand` being built. This should be called immediately after a :any:`Domain` is created
        via a call to
        :py:meth:`StrandBuilder.to`,
        :py:meth:`StrandBuilder.update_to`,
        or
        :py:meth:`StrandBuilder.loopout`, e.g.,

        .. code-block:: Python

            design.strand(0, 5).to(8).with_domain_sequence('AAA')\
                .cross(1).to(5).with_domain_sequence('TTT')\
                .loopout(2, 4).with_domain_sequence('CCCC')\
                .to(10).with_domain_sequence('GGGGG')

        :param sequence: the DNA sequence to assign to the :any:`Domain`
        :param assign_complement: whether to automatically assign the complement to existing :any:`Strand`'s
            bound to this :any:`Strand`. This has the same meaning as the parameter `assign_complement` in
            :py:meth:`DNADesign.assign_dna`.
        :return: self
        """
        last_domain = self.strand.domains[-1]
        self.design.assign_dna(strand=self.strand, sequence=sequence, domain=last_domain,
                               assign_complement=assign_complement)
        return self

    # remove quotes when Python 3.6 support dropped
    def with_label(self, label: StrandLabel) -> 'StrandBuilder':
        """
        Assigns `label` as label of the :any:`Strand` being built.

        .. code-block:: Python

            design.strand(0, 0).to(10).cross(1).to(5).with_label('scaffold')

        :param label: label to assign to the :any:`Strand`
        :return: self
        """
        self.strand.set_label(label)
        return self

    # remove quotes when Python 3.6 support dropped
    def with_domain_label(self, label: DomainLabel) -> 'StrandBuilder':
        """
        Assigns `label` as DNA sequence of the most recently created :any:`Domain` in
        the :any:`Strand` being built. This should be called immediately after a :any:`Domain` is created
        via a call to
        :py:meth:`StrandBuilder.to`,
        :py:meth:`StrandBuilder.update_to`,
        or
        :py:meth:`StrandBuilder.loopout`, e.g.,

        .. code-block:: Python

            design.strand(0, 5).to(8).with_domain_label('domain 1')\
                .cross(1).to(5).with_domain_label('domain 2')\
                .loopout(2, 4).with_domain_label('domain 3')\
                .to(10).with_domain_label('domain 4')

        :param label: label to assign to the :any:`Domain`
        :return: self
        """
        last_domain = self.strand.domains[-1]
        last_domain.set_label(label)
        return self


@dataclass
class Strand(_JSONSerializable, Generic[StrandLabel, DomainLabel]):
    """
    Represents a single strand of DNA.

    Each maximal portion that is continguous on a single :any:`Helix` is a :any:`Domain`.
    Crossovers from one :any:`Helix` to another are implicitly from the 3' end of one of this
    Strand's :any:`Domain`'s to the 5' end of the next :any:`Domain`.

    A portion of the :any:`Strand` not associated to any :any:`Helix` is represented by a :any:`Loopout`.
    Two :any:`Loopout`'s cannot occur consecutively on a :any:`Strand`, nor can a :any:`Strand`
    contain only a :any:`Loopout` but no :any:`Domain`.


    One can set the strand to be a scaffold in the constructor:

    .. code-block:: Python

        import scadnano as sc

        scaffold_domains = [ ... ]
        scaffold_strand = sc.Strand(domains=scaffold_domains, is_scaffold=True)

    or by calling :py:meth:`Strand.set_scaffold` on the :any:`Strand` object:

    .. code-block:: Python

        import scadnano as sc

        scaffold_domains = [ ... ]
        scaffold_strand = sc.Strand(domains=scaffold_domains)
        scaffold_strand.set_scaffold()

    Both will give the strand the same color that
    `cadnano <https://cadnano.org/>`_
    uses for the scaffold.
    """

    domains: List[Union[Domain[DomainLabel], Loopout]]
    """:any:`Domain`'s (or :any:`Loopout`'s) composing this Strand. 
    Each :any:`Domain` is contiguous on a single :any:`Helix` 
    and could be either single-stranded or double-stranded, 
    whereas each :any:`Loopout` is single-stranded and has no associated :any:`Helix`."""

    dna_sequence: Optional[str] = None
    """Do not assign directly to this field. Always use :any:`DNADesign.assign_dna` 
    (for complementarity checking) or :any:`Strand.set_dna_sequence` 
    (without complementarity checking, to allow mismatches)."""

    color: Optional[Color] = None
    """Color to show this strand in the main view. If not specified in the constructor,
    a color is assigned by cycling through a list of defaults given by 
    :meth:`ColorCycler.colors`"""

    idt: Optional[IDTFields] = None
    """Fields used when ordering strands from the synthesis company IDT 
    (Integrated DNA Technologies, Coralville, IA). If present (i.e., not equal to :const:`None`)
    then the method :py:meth:`DNADesign.write_idt_bulk_input_file` can be called to automatically
    generate an text file for ordering strands in test tubes: 
    https://www.idtdna.com/site/order/oligoentry,
    as can the method :py:meth:`DNADesign.write_idt_plate_excel_file` for writing a Microsoft Excel 
    file that can be uploaded to IDT's website for describing DNA sequences to be ordered in 96-well
    or 384-well plates."""

    use_default_idt: bool = False
    """If ``True``, assigns an :any:`IDTFields` to this :any:`Strand` with same naming convention as
    cadnano, i.e., :py:data:`IDTFields.name` = "ST{h5}[{s}]{h3}[{e}]", where {h5} and {h3} are the 
    :any:`Helix`'s of the 5' and 3' ends, respectively, of the :any:`Strand`, 
    and {s} and {e} are the respective start and end offsets on those helices.
    """

    is_scaffold: bool = False
    """Indicates whether this :any:`Strand` is a scaffold for a DNA origami. If any :any:`Strand` in a
    :any:`DNADesign` is a scaffold, then the design is considered a DNA origami design."""

    modification_5p: Optional[Modification5Prime] = None
    """5' modification; None if there is no 5' modification."""

    modification_3p: Optional[Modification3Prime] = None
    """3' modification; None if there is no 5' modification."""

    modifications_int: Dict[int, ModificationInternal] = field(default_factory=dict)
    """:any:`Modification`'s to the DNA sequence (e.g., biotin, Cy3/Cy5 fluorphores). Maps offset to 
    modification. If the internal modification is attached to a base 
    (e.g., internal biotin, /iBiodT/ from IDT), 
    then the offset is that of the base.
    If it goes between two bases 
    (e.g., internal Cy3, /iCy3/ from IDT),
    then the offset is that of the previous base, 
    e.g., to put a Cy3 between bases at offsets 3 and 4, the offset should be 3. 
    So for an internal modified base on a sequence of length n, the allowed offsets are 0,...,n-1,
    and for an internal modification that goes between bases, the allowed offsets are 0,...,n-2."""

    label: StrandLabel = None
    """Generic "label" object to associate to this :any:`Strand`.
    
    Useful for associating extra information with the Strand that will be serialized, for example,
    for DNA sequence design. It must be an object (e.g., a dict or primitive type such as str or int) 
    that is naturally JSON serializable. (Calling ``json.dumps`` on the object should succeed without
    having to specify a custom encoder.)
    """

    # not serialized; efficient way to see a list of all domains on a given helix
    _helix_idx_domain_map: Dict[int, List[Domain[DomainLabel]]] = field(
        init=False, repr=False, compare=False, default=None)

    def to_json_serializable(self, suppress_indent: bool = True):
        dct = OrderedDict()
        if self.color is not None:
            dct[color_key] = self.color.to_json_serializable(suppress_indent)
        if self.dna_sequence is not None:
            dct[dna_sequence_key] = self.dna_sequence
        if self.idt is not None:
            dct[idt_key] = self.idt.to_json_serializable(suppress_indent)
        dct[domains_key] = [domain.to_json_serializable(suppress_indent) for domain in self.domains]
        if hasattr(self, is_scaffold_key) and self.is_scaffold:
            dct[is_scaffold_key] = self.is_scaffold

        if self.modification_5p is not None:
            dct[modification_5p_key] = self.modification_5p.id
        if self.modification_3p is not None:
            dct[modification_3p_key] = self.modification_3p.id
        if len(self.modifications_int) > 0:
            mods_dict = {}
            for offset, mod in self.modifications_int.items():
                mods_dict[f"{offset}"] = mod.id
            dct[modifications_int_key] = NoIndent(mods_dict) if suppress_indent else mods_dict

        if self.label is not None:
            dct[strand_label_key] = self.label

        return dct

    def __post_init__(self):
        self._helix_idx_domain_map = defaultdict(list)

        for domain in self.domains:
            if domain.is_domain():
                self._helix_idx_domain_map[domain.helix].append(domain)

        for domain in self.domains:
            domain._parent_strand = self

        if len(self.domains) == 1:
            if self.first_domain().is_loopout():
                raise StrandError(self, 'strand cannot have a single Loopout as its only domain')

        for domain1, domain2 in _pairwise(self.domains):
            if domain1.is_loopout() and domain2.is_loopout():
                raise StrandError(self, 'cannot have two consecutive Loopouts in a strand')

        if self.use_default_idt:
            self.set_default_idt(True)

        self._ensure_modifications_legal()
        self._ensure_domains_nonoverlapping()

    def __eq__(self, other: 'Strand') -> bool:  # remove quotes when Python 3.6 support dropped
        if not isinstance(other, Strand):
            return False
        return self.domains == other.domains

    def __hash__(self):
        return hash(self.domains)

    def set_scaffold(self, is_scaf: bool = True):
        """Sets this :any:`Strand` as a scaffold. Alters color to default scaffold color.

        If `is_scaf` == ``False``, sets this strand as not a scaffold, and leaves the color alone."""
        self.is_scaffold = is_scaf
        if is_scaf:
            self.color = default_scaffold_color

    def set_label(self, label: StrandLabel):
        """Sets label of this :any:`Strand`."""
        self.label = label

    def set_color(self, color: Color):
        """Sets color of this :any:`Strand`."""
        self.color = color

    def set_default_idt(self, use_default_idt: bool = True, skip_scaffold: bool = True):
        """Sets idt field to be the default given the Domain data of this :any:`Strand`."""
        if skip_scaffold and self.is_scaffold:
            return
        self.use_default_idt = use_default_idt
        if use_default_idt:
            start_helix = self.first_bound_domain().helix
            end_helix = self.last_bound_domain().helix
            start_offset = self.first_bound_domain().offset_5p()
            end_offset = self.last_bound_domain().offset_3p()
            self.idt = IDTFields(name=f'ST{start_helix}[{start_offset}]{end_helix}[{end_offset}]')
        else:
            self.idt = None

    def set_modification_5p(self, mod: Modification5Prime = None):
        """Sets 5' modification to be `mod`."""
        self.modification_5p = mod

    def set_modification_3p(self, mod: Modification3Prime = None):
        """Sets 3' modification to be `mod`."""
        self.modification_3p = mod

    def remove_modification_5p(self):
        """Removes 5' modification."""
        self.modification_5p = None

    def remove_modification_3p(self):
        """Removes 3' modification."""
        self.modification_3p = None

    def set_modification_internal(self, idx: int, mod: ModificationInternal, warn_on_no_dna: bool = True):
        """Adds internal modification `mod` at given DNA index `idx`."""
        if idx < 0:
            raise IllegalDNADesignError('idx of modification must be nonnegative')
        if idx >= self.dna_length():
            raise IllegalDNADesignError(f'idx of modification must be at most length of DNA: '
                                        f'{self.dna_length()}')
        if self.dna_sequence is not None:
            if mod.allowed_bases is not None and self.dna_sequence[idx] not in mod.allowed_bases:
                raise IllegalDNADesignError(f'only bases {",".join(mod.allowed_bases)} are allowed at '
                                            f'index {idx}, but sequence has base {self.dna_sequence[idx]} '
                                            f'\nDNA sequence: {self.dna_sequence}'
                                            f'\nmodification: {mod}')
        elif warn_on_no_dna:
            print('WARNING: no DNA sequence has been assigned, so certain error checks on the internal '
                  'modification were not done. To be safe, first assign DNA, then add the modifications.')
        self.modifications_int[idx] = mod

    def remove_modification_internal(self, idx: int):
        """Removes internal modification at given DNA index `idx`."""
        if idx in self.modifications_int:
            del self.modifications_int[idx]

    def first_domain(self) -> Union[Domain[DomainLabel], Loopout]:
        """First domain (of type either :any:`Domain` or :any:`Loopout`) on this :any:`Strand`."""
        return self.domains[0]

    def last_domain(self) -> Union[Domain[DomainLabel], Loopout]:
        """Last domain (of type either :any:`Domain` or :any:`Loopout`) on this :any:`Strand`."""
        return self.domains[-1]

    def set_dna_sequence(self, sequence: str):
        """Set this :any:`Strand`'s DNA sequence to `seq`
        WITHOUT checking for complementarity with overlapping
        :any:`Strand`'s or automatically assigning their sequences.
        To assign a sequence to a :any:`Strand` and have the overlapping
        :any:`Strand`'s automatically have the appropriate Watson-Crick complements assigned,
        use :any:`DNADesign.assign_dna`.

        All whitespace in `sequence` is removed,
        and lowercase bases 'a', 'c', 'g', 't' are converted to uppercase.

        `sequence`, after all whitespace is removed, must be exactly the same length as
        :py:meth:`Strand.dna_length`.
        Wildcard symbols (:py:const:`DNA_case_wildcard`) are allowed to leave part of the DNA unassigned.
        """
        trimmed_seq = _remove_whitespace_and_uppercase(sequence)
        if len(trimmed_seq) != self.dna_length():
            domain = self.first_domain()
            raise StrandError(self, f"strand starting at helix {domain.helix} offset {domain.offset_5p()} "
                                    f"has length {self.dna_length()}, but you attempted to assign a "
                                    f"DNA sequence of length {len(trimmed_seq)}: {sequence}")
        self.dna_sequence = trimmed_seq

    def dna_length(self) -> int:
        """Return sum of DNA length of :any:`Domain`'s and :any:`Loopout`'s of this :any:`Strand`."""
        acc = 0
        for domain in self.domains:
            acc += domain.dna_length()
        return acc

    def bound_domains(self) -> List[Domain[DomainLabel]]:
        """:any:`Domain`'s of this :any:`Strand` that are not :any:`Loopout`'s."""
        return [domain for domain in self.domains if domain.is_domain()]

    def offset_5p(self) -> int:
        """5' offset of this entire :any:`Strand`, INCLUSIVE."""
        return self.first_domain().offset_5p()

    def offset_3p(self) -> int:
        """3' offset of this entire :any:`Strand`, INCLUSIVE."""
        return self.last_domain().offset_3p()

    def overlaps(self, other: 'Strand') -> bool:  # remove quotes when Python 3.6 support dropped
        """Indicates whether `self` overlaps `other_strand`, meaning that the set of offsets occupied
        by `self` has nonempty intersection with those occupied by `other_strand`."""
        for domain_self in self.bound_domains():
            for domain_other in other.bound_domains():
                if domain_self.overlaps(domain_other):
                    return True
        return False

    def assign_dna_complement_from(self, other: 'Strand'):  # remove quotes when Python 3.6 support dropped
        """Assuming a DNA sequence has been assigned to `other`, assign its Watson-Crick
        complement to the portions of this Strand that are bound to `other`.

        Generally this is not called directly; use :py:meth:`DNADesign.assign_dna` to assign
        a DNA sequence to a :any:`Strand`. The method :py:meth:`DNADesign.assign_dna` will calculate
        which other :any:`Strand`'s need
        to be assigned via :py:meth:`Strand.assign_dna_complement_from`.

        However, it is permitted to assign the field :py:data:`Strand.dna_sequence` directly
        via the method :py:meth:`Strand.set_dna_sequence`.
        This is used, for instance, to assign a DNA sequence to a :any:`Strand` bound to another
        :any:`Strand`
        with an assigned DNA sequence where they overlap. In this case no error checking
        about sequence complementarity is done. This can be used to intentionally assign *mismatching*
        DNA sequences to :any:`Strand`'s that are bound on a :any:`Helix`."""

        already_assigned = self.dna_sequence is not None

        # put DNA sequences to assign to domains in List, one position per domain
        strand_complement_builder = []
        if already_assigned:
            for domain in self.domains:
                strand_complement_builder.append(domain.dna_sequence())
        else:
            for domain in self.domains:
                wildcards = DNA_base_wildcard * domain.dna_length()
                strand_complement_builder.append(wildcards)

        for (domain_idx, domain_self) in enumerate(self.domains):
            if domain_self.is_loopout():
                domain_self_dna_sequence = DNA_base_wildcard * domain_self.dna_length()
            else:
                helix = domain_self.helix

                # for helix, domains_on_helix_self in self._helix_idx_domain_map.items():
                domains_on_helix_other = other._helix_idx_domain_map[helix]
                # for domain_self in domains_on_helix_self:
                overlaps = []
                for domain_other in domains_on_helix_other:
                    if domain_self != domain_other and domain_self.overlaps(domain_other):
                        overlap = domain_self.compute_overlap(domain_other)
                        overlaps.append((overlap, domain_other))

                overlaps.sort()

                domain_complement_builder = []
                start_idx = domain_self.start
                # repeatedly insert wildcards into gaps, then reverse WC complement
                for ((overlap_left, overlap_right), domain_other) in overlaps:
                    # wildcards = DNA_base_wildcard * (overlap_left - start_idx)
                    num_wildcard_bases = domain_self.dna_length_in(start_idx, overlap_left - 1)
                    wildcards = DNA_base_wildcard * num_wildcard_bases

                    other_seq = domain_other.dna_sequence_in(overlap_left, overlap_right - 1)
                    overlap_complement = wc(other_seq)
                    domain_complement_builder.append(wildcards)
                    domain_complement_builder.append(overlap_complement)
                    start_idx = overlap_right

                # last wildcard for gap between last overlap and end
                # last_wildcards = DNA_base_wildcard * (domain_self.end - start_idx)
                num_wildcard_bases = domain_self.dna_length_in(start_idx, domain_self.end - 1)
                last_wildcards = DNA_base_wildcard * num_wildcard_bases

                domain_complement_builder.append(last_wildcards)

                # If pointing left, each individual overlap sequence was reverse orientation in wc(),
                # but not the list of all of them put together until now.
                if not domain_self.forward:
                    domain_complement_builder.reverse()

                domain_self_dna_sequence = ''.join(domain_complement_builder)

            # merge with existing pre-assigned sequence
            existing_domain_self_dna_sequence = strand_complement_builder[domain_idx]
            merged_domain_self_dna_sequence = _string_merge_wildcard(domain_self_dna_sequence,
                                                                     existing_domain_self_dna_sequence,
                                                                     DNA_base_wildcard)
            strand_complement_builder[domain_idx] = merged_domain_self_dna_sequence

        strand_complement = ''.join(strand_complement_builder)
        new_dna_sequence = strand_complement
        if self.dna_sequence is not None:
            try:
                new_dna_sequence = _string_merge_wildcard(self.dna_sequence, new_dna_sequence,
                                                          DNA_base_wildcard)
            except ValueError:
                domain_self = self.first_domain()
                domain_other = other.first_domain()
                msg = f'strand starting at helix {domain_self.helix}, offset {domain_self.offset_5p()} ' \
                      f'has length ' \
                      f'{self.dna_length()} and already has a partial DNA sequence assignment of length ' \
                      f'{len(self.dna_sequence)}, which is \n' \
                      f'{self.dna_sequence}, ' \
                      f'but you tried to assign sequence of length {len(new_dna_sequence)} to it, which ' \
                      f'is\n{new_dna_sequence} (this assignment was indirect, since you assigned directly ' \
                      f'to a strand bound to this one). This occurred while directly assigning a DNA ' \
                      f'sequence to the strand whose 5\' end is at helix {domain_other.helix}, and is of ' \
                      f'length {other.dna_length()}.'
                raise IllegalDNADesignError(msg)

        self.set_dna_sequence(new_dna_sequence)
        # self.dna_sequence = _pad_dna(new_dna_sequence, self.dna_length())

    def insert_domain(self, order, domain):
        # Only intended to be called by DNADesign.insert_domain
        self.domains.insert(order, domain)
        domain._parent_strand = self
        if domain.is_domain():
            self._helix_idx_domain_map[domain.helix].append(domain)
        if self.use_default_idt:
            self.set_default_idt()

        # add wildcard symbols to DNA sequence to maintain its length
        if self.dna_sequence is not None:
            start_idx = self.dna_index_start_domain(domain)
            end_idx = start_idx + domain.dna_length()
            prefix = self.dna_sequence[:start_idx]
            suffix = self.dna_sequence[start_idx:]
            new_wildcards = DNA_base_wildcard * (end_idx - start_idx)
            self.dna_sequence = prefix + new_wildcards + suffix

    def remove_domain(self, domain: Union[Domain[DomainLabel], Loopout]):
        # Only intended to be called by DNADesign.remove_domain

        # remove relevant portion of DNA sequence to maintain its length
        if self.dna_sequence is not None:
            start_idx = self.dna_index_start_domain(domain)
            end_idx = start_idx + domain.dna_length()
            prefix = self.dna_sequence[:start_idx]
            suffix = self.dna_sequence[end_idx:]
            self.dna_sequence = prefix + suffix

        self.domains.remove(domain)
        domain._parent_strand = None
        if domain.is_domain():
            self._helix_idx_domain_map[domain.helix].remove(domain)
        if self.use_default_idt:
            self.set_default_idt()

    def dna_index_start_domain(self, domain: Domain[DomainLabel]):
        """
        Returns index in DNA sequence of domain, e.g., if there are five domains

        012 3 45 678 9
        AAA-C-GG-TTT-ACGT

        Then their indices, respectively in order, are 0, 3, 4, 6, 9.

        :param domain: :any: to find the start DNA index of
        :return: index (within DNA sequence string) of substring of DNA starting with given :any:`Domain`
        """
        domain_order = self.domains.index(domain)
        idx = sum(self.domains[i].dna_length() for i in range(domain_order))
        return idx

    def contains_loopouts(self) -> bool:
        for domain in self.domains:
            if domain.is_loopout():
                return True
        return False

    def first_bound_domain(self) -> Domain[DomainLabel]:
        """First :any:`Domain` (i.e., not a :any:`Loopout`) on this :any:`Strand`.

        Currently the first and last strand must not be :any:`Loopout`'s, so this should return the same
        domain as :py:meth:`Strand.first_domain`, but in case an initial or final :any:`Loopout` is
        supported in the future, this method is provided."""
        for domain in self.domains:
            if domain.is_domain():
                return domain

    def last_bound_domain(self) -> Domain[DomainLabel]:
        """Last :any:`Domain` (i.e., not a :any:`Loopout`) on this :any:`Strand`.

        Currently the first and last strand must not be :any:`Loopout`'s, so this should return the same
        domain as :py:meth:`Strand.first_domain`, but in case an initial or final :any:`Loopout` is
        supported in the future, this method is provided."""
        domain_rev = list(self.domains)
        domain_rev.reverse()
        for domain in domain_rev:
            if domain.is_domain():
                return domain

    def reverse(self):
        """
        Reverses "polarity" of this :any:`Strand`.

        Does NOT check whether this keeps the :any:`DNADesign` legal, so be cautious in calling this method
        directly. To reverse every :any:`Strand`, called :py:meth:`DNADesign.reverse_all`.
        If the design was legal before, it will be legal after calling that method.
        """
        self.domains.reverse()
        for domain in self.bound_domains():
            domain.forward = not domain.forward

    @staticmethod
    def from_json(json_map: dict) -> 'Strand':  # remove quotes when Python 3.6 support dropped
        domain_jsons = mandatory_field(Strand, json_map, domains_key, *legacy_domains_keys)
        if len(domain_jsons) == 0:
            raise IllegalDNADesignError(f'{domains_key} list cannot be empty')

        domains = []
        for domain_json in domain_jsons:
            if loopout_key in domain_json:
                domains.append(Loopout.from_json(domain_json))
            else:
                domains.append(Domain.from_json(domain_json))
        if isinstance(domains[0], Loopout):
            raise IllegalDNADesignError('Loopout at beginning of Strand not supported')
        if isinstance(domains[-1], Loopout):
            raise IllegalDNADesignError('Loopout at end of Strand not supported')

        is_scaffold = json_map.get(is_scaffold_key, False)

        dna_sequence = optional_field(None, json_map, dna_sequence_key, *legacy_dna_sequence_keys)

        idt = json_map.get(idt_key)
        color_str = json_map.get(color_key,
                                 default_scaffold_color if is_scaffold else default_strand_color)
        if isinstance(color_str, int):
            def decimal_int_to_hex(d: int) -> str:
                return "#" + "{0:#08x}".format(d, 8)[2:]

            color_str = decimal_int_to_hex(color_str)
        color = Color(hex_string=color_str)

        label = json_map.get(strand_label_key)

        return Strand(
            domains=domains,
            dna_sequence=dna_sequence,
            color=color,
            idt=idt,
            is_scaffold=is_scaffold,
            label=label,
        )

    def _ensure_modifications_legal(self, check_offsets_legal=False):
        if check_offsets_legal:
            if self.dna_sequence is None:
                raise IllegalDNADesignError(f"must assign DNA sequence first")
            mod_i_offsets_list = list(self.modifications_int.keys())
            min_offset = min(mod_i_offsets_list) if len(mod_i_offsets_list) > 0 else None
            max_offset = max(mod_i_offsets_list) if len(mod_i_offsets_list) > 0 else None
            if min_offset is not None and min_offset < 0:
                raise IllegalDNADesignError(f"smallest offset is {min_offset} but must be nonnegative: "
                                            f"{self.modifications_int}")
            if max_offset is not None and max_offset > len(self.dna_sequence):
                raise IllegalDNADesignError(f"largeest offset is {max_offset} but must be at most "
                                            f"{len(self.dna_sequence)}: "
                                            f"{self.modifications_int}")

    def _ensure_domains_nonoverlapping(self):
        for d1, d2 in itertools.combinations(self.domains, 2):
            if isinstance(d1, Domain) and isinstance(d2, Domain) and d1.overlaps_illegally(d2):
                raise StrandError(self, f'two domains on strand overlap:'
                                        f'\n{d1}'
                                        f'\n{d2}')

    def idt_dna_sequence(self):
        self._ensure_modifications_legal(check_offsets_legal=True)

        ret_list = []
        if self.modification_5p is not None:
            ret_list.append(self.modification_5p.idt_text)

        for offset, base in enumerate(self.dna_sequence):
            ret_list.append(base)
            if offset in self.modifications_int:  # if internal mod attached to base, replace base
                mod = self.modifications_int[offset]
                if mod.allowed_bases is not None:
                    if base not in mod.allowed_bases:
                        msg = f'internal modification {mod} can only replace one of these bases: ' \
                              f'{",".join(mod.allowed_bases)}, but the base at offset {offset} is {base}'
                        raise IllegalDNADesignError(msg)
                    ret_list[-1] = mod.idt_text  # replace base with modified base
                else:
                    ret_list.append(mod.idt_text)  # append modification between two bases

        if self.modification_3p is not None:
            ret_list.append(self.modification_3p.idt_text)

        return ''.join(ret_list)

    def unmodified_version(self):
        strand_nomods = replace(self, modification_3p=None, modification_5p=None, modifications_int={})
        return strand_nomods


def _pad_and_remove_whitespace_and_uppercase(sequence: str, strand: Strand, start: int = 0):
    sequence = _remove_whitespace_and_uppercase(sequence)
    padded_sequence = _pad_dna(sequence, strand.dna_length(), start)
    return padded_sequence


def _remove_whitespace_and_uppercase(sequence):
    sequence = re.sub(r'\s*', '', sequence)
    sequence = sequence.upper()
    return sequence


def _pad_dna(sequence: str, length: int, start: int = 0) -> str:
    """Return `sequence` modified to have length `length`.

    If len(sequence) < length, pad with  :py:data:`DNA_base_wildcard`.

    If len(sequence) > length, remove extra symbols, from 0 up to `start`, and at the end.

    :param sequence: sequence to pad
    :param length: final length of padded sequence
    :param start: index at which to start padding. If not specified, defaults to 0
    :return: padded sequence
    """
    if start < 0:
        raise ValueError(f'cannot pad DNA with negative start, but start = {start}')
    elif start >= length:
        raise ValueError(f'cannot pad DNA with start >= length, but start = {start} and '
                         f'length = {length}')
    if len(sequence) > length:
        sequence = sequence[start:start + length]
    elif len(sequence) < length:
        prefix = DNA_base_wildcard * start
        suffix = DNA_base_wildcard * (length - len(sequence) - start)
        sequence = prefix + sequence + suffix
    return sequence


def _string_merge_wildcard(s1: str, s2: str, wildcard: str) -> str:
    """Takes a "union" of two equal-length strings `s1` and `s2`.
    Whenever one has a symbol `wildcard` and the other does not, the result has the non-wildcard symbol.

    Raises :py:class:`ValueError` if `s1` and `s2` are not the same length or do not agree on non-wildcard
    symbols at any position."""
    if len(s1) != len(s2):
        raise ValueError(f'\ns1={s1} and\ns2={s2}\nare not the same length.')
    union_builder = []
    for i in range(len(s1)):
        c1, c2 = s1[i], s2[i]
        if c1 == wildcard:
            union_builder.append(c2)
        elif c2 == wildcard:
            union_builder.append(c1)
        elif c1 != c2:
            raise ValueError(f's1={s1} and s2={s2} have unequal symbols {c1} and {c2} at position {i}.')
        elif c1 == c2:
            union_builder.append(c1)
        else:
            raise AssertionError('should be unreachable')
    return ''.join(union_builder)


class IllegalDNADesignError(ValueError):
    """Indicates that some aspect of the :any:`DNADesign` object is illegal."""

    def __init__(self, the_cause: str):
        self.cause = the_cause

    # __str__ is to print() the value
    def __str__(self):
        return repr(self.cause)


class StrandError(IllegalDNADesignError):
    """Indicates that the :any:`DNADesign` is illegal due to some specific :any:`Strand`.
    Information about the :any:`Strand` is embedded in the error message when this exception is
    raised that helps to identify which :any:`Strand` caused the problem."""

    def __init__(self, strand: Strand, the_cause: str):
        first_domain = strand.first_bound_domain()
        last_domain = strand.last_bound_domain()

        msg = (f'{the_cause}\n'
               f'strand length        =  {strand.dna_length()}\n'
               f'DNA length           =  {len(strand.dna_sequence) if strand.dna_sequence else "N/A"}\n'
               f'DNA sequence         =  {strand.dna_sequence}'
               f"strand 5' helix      =  {first_domain.helix if first_domain else 'N/A'}\n"
               f"strand 5' end offset =  {first_domain.offset_5p() if first_domain else 'N/A'}\n"
               f"strand 3' helix      =  {last_domain.helix if last_domain else 'N/A'}\n"
               f"strand 3' end offset =  {last_domain.offset_3p() if last_domain else 'N/A'}\n")

        super().__init__(msg)
        # super(IllegalDNADesignError, self).__init__(msg)


def _plates(idt_strands):
    plates = set()
    for strand in idt_strands:
        if strand.idt is not None and strand.idt.plate is not None:
            plates.add(strand.idt.plate)
    return list(plates)


_96WELL_PLATE_ROWS: List[str] = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
_96WELL_PLATE_COLS: List[int] = list(range(1, 13))

_384WELL_PLATE_ROWS: List[str] = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N',
                                  'O',
                                  'P']
_384WELL_PLATE_COLS: List[int] = list(range(1, 25))


@enum.unique
class PlateType(int, enum.Enum):
    """Represents two different types of plates in which DNA sequences can be ordered."""

    wells96 = 96
    """96-well plate."""

    wells384 = 384
    """384-well plate."""

    def rows(self) -> List[str]:
        return _96WELL_PLATE_ROWS if self is PlateType.wells96 else _384WELL_PLATE_ROWS

    def cols(self) -> List[int]:
        return _96WELL_PLATE_COLS if self is PlateType.wells96 else _384WELL_PLATE_COLS


class _PlateCoordinate:

    def __init__(self, plate_type: PlateType):
        self._plate_type = plate_type
        self._plate: int = 1
        self._row_idx: int = 0
        self._col_idx: int = 0

    def increment(self):
        self._row_idx += 1
        if self._row_idx == len(self._plate_type.rows()):
            self._row_idx = 0
            self._col_idx += 1
            if self._col_idx == len(self._plate_type.cols()):
                self._col_idx = 0
                self._plate += 1

    def plate(self) -> int:
        return self._plate

    def row(self) -> str:
        return self._plate_type.rows()[self._row_idx]

    def col(self) -> int:
        return self._plate_type.cols()[self._col_idx]

    def well(self) -> str:
        return f'{self.row()}{self.col()}'


def remove_helix_idxs_if_default(helices: List[Dict]):
    # removes indices from each helix if they are the default (order of appearance in list)
    default = True
    for expected_idx, helix in enumerate(helices):
        idx = helix[idx_on_helix_key]
        if idx != expected_idx:
            default = False
            break

    if default:
        for helix in helices:
            del helix[idx_on_helix_key]


def add_quotes(string: str) -> str:
    # adds quotes around a string
    return f'"{string}"'


def mandatory_field(ret_type: Type, json_map: dict, main_key: str, *legacy_keys: str):
    # should be called from function whose return type is the type being constructed from JSON, e.g.,
    # DNADesign or Strand, given by ret_type. This helps give a useful error message
    for key in (main_key,) + legacy_keys:
        if key in json_map:
            return json_map[key]
    ret_type_name = ret_type.__name__
    msg_about_keys = f'the key "{main_key}"'
    if len(legacy_keys) > 0:
        msg_about_keys += f" (or any of the following legacy keys: {', '.join(map(add_quotes, legacy_keys))})"
    msg = f'I was looking for {msg_about_keys} in the JSON encoding of a {ret_type_name}, ' \
          f'but I did not find it.' \
          f'\n\nThis occurred when reading this JSON object:\n{json_map}'
    raise IllegalDNADesignError(msg)


def optional_field(default_value, json_map: dict, main_key: str, *legacy_keys: str):
    # like dict.get, except that it checks for multiple keys
    for key in (main_key,) + legacy_keys:
        if key in json_map:
            return json_map[key]
    return default_value


@dataclass
class Geometry(_JSONSerializable):
    """Parameters controlling some geometric visualization/physical aspects of Design."""

    rise_per_base_pair: float = 0.332
    """Distance in nanometers between two adjacent base pairs along the length of a DNA double helix."""

    helix_radius: float = 1.0
    """Radius of a DNA helix in nanometers."""

    bases_per_turn: float = 10.5
    """Number of DNA base pairs in a full turn of DNA."""

    minor_groove_angle: float = 150.0
    """Minor groove angle in degrees."""

    inter_helix_gap: float = 0.5
    """Gap between helices in nanometers (due to electrostatic repulsion; needed to display to scale)."""

    def is_default(self):
        return self == _default_geometry

    @staticmethod
    def from_json(json_map: dict) -> 'Geometry':  # remove quotes when Python 3.6 support dropped
        geometry = Geometry()
        geometry.rise_per_base_pair = optional_field(_default_geometry.rise_per_base_pair, json_map,
                                                     rise_per_base_pair_key, *legacy_rise_per_base_pair_keys)
        geometry.helix_radius = optional_field(_default_geometry.helix_radius, json_map, helix_radius_key)
        geometry.bases_per_turn = optional_field(_default_geometry.bases_per_turn, json_map,
                                                 bases_per_turn_key)
        geometry.minor_groove_angle = optional_field(_default_geometry.minor_groove_angle, json_map,
                                                     minor_groove_angle_key)
        geometry.inter_helix_gap = optional_field(_default_geometry.inter_helix_gap, json_map,
                                                  inter_helix_gap_key)
        return geometry

    @staticmethod
    def keys() -> List[str]:
        return [rise_per_base_pair_key, helix_radius_key, bases_per_turn_key, minor_groove_angle_key,
                inter_helix_gap_key]

    def values(self) -> List[float]:
        return [self.rise_per_base_pair, self.helix_radius, self.bases_per_turn, self.minor_groove_angle,
                self.inter_helix_gap]

    @staticmethod
    def default_values() -> List[float]:
        return _default_geometry.values()

    def to_json_serializable(self, suppress_indent: bool = True):
        dct = OrderedDict()
        for name, val, val_default in zip(Geometry.keys(), self.values(), Geometry.default_values()):
            if val != val_default:
                dct[name] = val
        return dct


_default_geometry = Geometry()


@dataclass
class DNADesign(_JSONSerializable):
    """Object representing the entire design of the DNA structure."""

    strands: List[Strand]
    """All of the :any:`Strand`'s in this :any:`DNADesign`.
    
    Required field."""

    helices: Dict[int, Helix] = None
    """All of the :any:`Helix`'s in this :any:`DNADesign`. 
    This is a dictionary mapping index to the :any:`Helix` with that index; if helices have indices 
    0, 1, ..., num_helices-1, then this can be used as a list of Helices. 
    
    Optional field. If not specified, then the number of helices will be just large enough to store the
    largest index :py:data:`Domain.helix` 
    stored in any :any:`Domain` 
    in :py:data:`DNADesign.strands`."""

    grid: Grid = Grid.square
    """Common choices for how to arrange helices relative to each other.
    
    Optional field."""

    major_tick_distance: int = -1
    """Distance between major ticks (bold) delimiting boundaries between bases.
    
    Optional field.
    If not specified, default value is 8 unless overridden by :py:data:`DNADesign.grid`.
    If 0 then no major ticks are drawn.
    If negative then the default value is assumed, but `major_tick_distance` is not stored in the JSON file
    when serialized.
    If :any:`DNADesign.grid` = :any:`Grid.square` then the default value is 8.
    If :any:`DNADesign.grid` = :any:`Grid.hex` or :any:`Grid.honeycomb` then the default value is 7."""

    helices_view_order: List[int] = None
    """A list of the order in which the helix should be displayed in the main view of scadnano.
       
    This list must be a permutation containing each integer 0, 1, 2, ..., len(helices)-1 exactly once.

    Optional field. If not specified, it will be set to the identity permutation [0, ..., len(helices)-1].
    """

    geometry: Geometry = field(default_factory=lambda: Geometry())
    """Controls some geometric/physical aspects of this :any:`DNADesign`."""

    automatically_assign_color: bool = field(repr=False, default=True)
    """If `automatically_assign_color` = ``False``, then for any :any:`Strand` such that
    `Strand.color` = ``None``, do not automatically assign a :any:`Color` to it. 
    In this case color will be set to its default of ``None`` and will not be
    written to the JSON with :py:meth:`DNADesign.write_scadnano_file` or :py:meth:`DNADesign.to_json`."""

    color_cycler: ColorCycler = field(default_factory=lambda: ColorCycler(), init=False)

    def __init__(self, *,
                 helices: Optional[Union[List[Helix], Dict[int, Helix]]] = None,
                 strands: List[Strand] = None,
                 helix_template: Optional[Helix] = None,
                 num_helices: Optional[int] = None,
                 grid: Grid = Grid.none,
                 major_tick_distance: int = -1,
                 helices_view_order: List[int] = None,
                 geometry: Geometry = None):
        """
        :param helices: List of :any:`Helix`'s; if missing, set based on either `helix_template` and
            `num_helices`, or based on `strands`.
            Mutually exlusive with  `helix_template` and `num_helices`
        :param strands: List of :any:`Strand`'s. If missing, will be empty.
        :param helix_template: If specified, `num_helices` must be specified.
            That many helices will be created,
            modeled after this Helix. This Helix will not be any of them, so modifications to it will not
            affect the :any:`DNADesign` after it is created. The ``idx`` field of `helix_template` will be
            ignored, and the ``idx`` fields of the created helices will be 0 through `num_helices` - 1.
            Mutually exclusive with `helices`.
        :param num_helices: Number of :any:`Helix`'s to create, each of which is copied from `helix_template`.
            If specified, `helix_template` must be specified
            Mutually exclusive with `helices`.
        :param grid: :any:`Grid` to use.
        :param major_tick_distance: regularly spaced major ticks between all helices.
            :any:`Helix.major_tick_distance` overrides this value for any :any:`Helix` in which it is
            specified.
        :param helices_view_order: order in which to view helices from top to bottom in web interface
            main view
        :param geometry: geometric physical parameters for visualization.
            If not specified, a default set of parameters from the literature are used.
        """
        self.helices = helices
        self.strands = strands
        self.grid = grid
        self.major_tick_distance = major_tick_distance
        self.helices_view_order = helices_view_order
        self.color_cycler = ColorCycler()
        self.geometry = Geometry() if geometry is None else geometry

        if self.strands is None:
            self.strands = []

        if self.major_tick_distance < 0 or self.major_tick_distance is None:
            self.major_tick_distance = default_major_tick_distance(self.grid)

        if self.helices is not None and (helix_template is not None or num_helices is not None):
            raise IllegalDNADesignError('helices is mutually exclusive with helix_template and num_helices; '
                                        'you must specified the first, or the latter two')

        if (helix_template is not None and num_helices is None) or (
                helix_template is None and num_helices is not None):
            raise IllegalDNADesignError('helix type must be specified if and only if num_helices is')

        if self.helices is None:
            if helix_template is not None and num_helices is not None:
                self.helices = {idx: copy.deepcopy(helix_template) for idx in range(num_helices)}
                for idx, helix in self.helices.items():
                    replace(helix, idx=idx)
            elif len(self.strands) > 0:
                max_helix_idx = max(
                    domain.helix for strand in self.strands for domain in strand.bound_domains())
                self.helices = {idx: Helix(idx=idx) for idx in range(max_helix_idx + 1)}
            else:
                self.helices = {}

        self.helices = DNADesign._normalize_helices_as_dict(self.helices)

        self.__post_init__()

    def __post_init__(self):
        # XXX: exact order of these calls is important
        self._ensure_helices_distinct_objects()
        self._ensure_strands_distinct_objects()
        self._set_helices_idxs()
        self._set_helices_grid_positions()
        self._build_domains_on_helix_lists()
        self._set_helices_min_max_offsets(update=False)
        self._check_legal_design()

        self._set_and_check_helices_view_order()

        if self.automatically_assign_color:
            self._assign_colors_to_strands()

    def _assign_colors_to_strands(self):
        # if color not specified, pick one by cycling through list of staple colors,
        # unless caller specified not to
        for strand in self.strands:
            self._assign_color_to_strand(strand)

    def _assign_color_to_strand(self, strand: Strand):
        if strand.color is None and self.automatically_assign_color:
            if strand.is_scaffold:
                strand.color = default_scaffold_color
            else:
                strand.color = next(self.color_cycler)

    @staticmethod
    def from_scadnano_file(filename: str) -> 'DNADesign':  # remove quotes when Python 3.6 support dropped
        """
        Loads a :any:`DNADesign` from the file with the given name.

        :param filename: name of the file with the design. Should be a JSON file ending in .dna
        :return: DNADesign described in the file
        """
        with open(filename) as f:
            json_str = f.read()
        return DNADesign.from_scadnano_json_str(json_str)

    @staticmethod
    def from_scadnano_json_str(json_str: str) -> 'DNADesign':  # remove quotes when Python 3.6 support dropped
        """
        Loads a :any:`DNADesign` from the given JSON string.

        :param json_str: JSON description of the :any:`DNADesign`
        :return: DNADesign described in the JSON string
        """
        json_map = json.loads(json_str)
        try:
            design = DNADesign.from_scadnano_json_map(json_map)
            return design
        except KeyError as e:
            raise IllegalDNADesignError(f'I was expecting a JSON key but did not find it: {e}')

    @staticmethod
    def from_scadnano_json_map(
            json_map: dict) -> 'DNADesign':  # remove quotes when Python 3.6 support dropped
        """
        Loads a :any:`DNADesign` from the given JSON object (i.e., Python object obtained by calling
        json.loads(json_str) from a string representing contents of a JSON file.

        :param json_map: JSON map describing the :any:`DNADesign`
        :return: DNADesign described in the object
        """
        # version = json_map.get(version_key, initial_version)  # not sure what to do with this

        grid = optional_field(Grid.none, json_map, grid_key)
        grid_is_none = grid == Grid.none

        if major_tick_distance_key in json_map:
            major_tick_distance = json_map[major_tick_distance_key]
        elif not grid_is_none:
            if grid in [Grid.hex, Grid.honeycomb]:
                major_tick_distance = 7
            else:
                major_tick_distance = 8
        else:
            major_tick_distance = -1

        helices = []
        deserialized_helices_list = json_map[helices_key]
        num_helices = len(deserialized_helices_list)

        # create Helices
        idx_default = 0
        for helix_json in deserialized_helices_list:
            helix = Helix.from_json(helix_json)
            if grid_is_none and grid_position_key in helix_json:
                raise IllegalDNADesignError(
                    f'grid is none, but Helix {idx_default} has grid_position = {helix_json[grid_position_key]}')
            elif not grid_is_none and position_key in helix_json:
                raise IllegalDNADesignError(
                    f'grid is not none, but Helix {idx_default} has position = ${helix_json[position_key]}')
            helices.append(helix)
            idx_default += 1

        # view order of helices
        helices_view_order = json_map.get(helices_view_order_key)
        if helices_view_order is not None:
            helix_idxs = [helix.idx for helix in helices]
            if len(helices_view_order) != num_helices:
                raise IllegalDNADesignError(f'length of helices ({num_helices}) does not match '
                                            f'length of helices_view_order ({len(helices_view_order)})')
            if sorted(helices_view_order) != sorted(helix_idxs):
                raise IllegalDNADesignError(f'helices_view_order = {helices_view_order} is not a '
                                            f'permutation of the set of helices {helix_idxs}')

        # strands
        strands = []
        strand_jsons = mandatory_field(DNADesign, json_map, strands_key)
        for strand_json in strand_jsons:
            strand = Strand.from_json(strand_json)
            strands.append(strand)

        # modifications in whole design
        if design_modifications_key in json_map:
            all_mods_json = json_map[design_modifications_key]
            all_mods = {}
            for mod_key, mod_json in all_mods_json.items():
                mod = Modification.from_json(mod_json)
                mod = dataclasses.replace(mod, id=mod_key)
                all_mods[mod_key] = mod
            DNADesign.assign_modifications_to_strands(strands, strand_jsons, all_mods)

        geometry = None
        if geometry_key in json_map:
            geometry = Geometry.from_json(json_map[geometry_key])

        return DNADesign(
            helices=helices,
            strands=strands,
            grid=grid,
            major_tick_distance=major_tick_distance,
            helices_view_order=helices_view_order,
            geometry=geometry,
        )

    def to_json_serializable(self, suppress_indent: bool = True):
        dct = OrderedDict()
        dct[version_key] = __version__
        dct[grid_key] = str(self.grid)[5:]  # remove prefix 'Grid.'

        if not self.geometry.is_default():
            dct[geometry_key] = self.geometry.to_json_serializable(suppress_indent)

        if self.major_tick_distance >= 0 and (
                self.major_tick_distance != default_major_tick_distance(self.grid)):
            dct[major_tick_distance_key] = self.major_tick_distance

        dct[helices_key] = [helix.to_json_serializable(suppress_indent) for helix in
                            self.helices.values()]

        # remove idx key from list of helices if they have the default index
        unwrapped_helices = dct[helices_key]
        if len(unwrapped_helices) > 0 and isinstance(unwrapped_helices[0], NoIndent):
            unwrapped_helices = [wrapped.value for wrapped in unwrapped_helices]
        remove_helix_idxs_if_default(unwrapped_helices)

        default_helices_view_order = list(range(0, len(self.helices)))
        if self.helices_view_order != default_helices_view_order:
            dct[helices_view_order_key] = NoIndent(self.helices_view_order)

        # modifications
        mods = self._all_modifications()
        if len(mods) > 0:
            mods_dict = {}
            for mod in mods:
                if mod.id not in mods_dict:
                    mods_dict[mod.id] = mod.to_json_serializable(suppress_indent)
            dct[design_modifications_key] = mods_dict

        dct[strands_key] = [strand.to_json_serializable(suppress_indent) for strand in self.strands]

        for helix_list_order, helix in enumerate(self.helices.values()):
            helix_json = dct[helices_key][helix_list_order]
            if suppress_indent and hasattr(helix_json, 'value'):
                helix_json = helix_json.value  # get past NoIndent surrounding helix, if it is there
            # XXX: no need to check here because key was already deleted by Helix.to_json_serializable
            # max_offset still needs to be checked here since it requires global knowledge of Strands
            # if 0 == helix_json[min_offset_key]:
            #     del helix_json[min_offset_key]
            max_offset = max((domain.end for domain in helix.domains), default=-1)
            if max_offset == helix_json[max_offset_key]:
                del helix_json[max_offset_key]

        return dct

    @property
    def scaffold(self) -> Optional[Strand]:
        """Returns the first scaffold in this :any:`DNADesign`, if there is one, or ``None`` otherwise."""
        for strand in self.strands:
            if strand.is_scaffold:
                return strand
        return None

    @staticmethod
    def _normalize_helices_as_dict(helices: Union[List[Helix], Dict[int, Helix]]) -> Dict[int, Helix]:
        def idx_of(helix: Helix, order: int):
            return order if helix.idx is None else helix.idx

        if isinstance(helices, list):
            indices = [idx_of(helix, idx) for idx, helix in enumerate(helices)]
            if len(set(indices)) < len(indices):
                duplicates = [index for index, count in Counter(indices).items() if count > 1]
                raise IllegalDNADesignError(
                    'No two helices can share an index, but these indices appear on '
                    f'multiple helices: {", ".join(map(str, duplicates))}')
            helices = {idx_of(helix, idx): helix for idx, helix in enumerate(helices)}

        for idx, helix in helices.items():
            helix.idx = idx

        return helices

    @staticmethod
    def assign_modifications_to_strands(strands: List[Strand], strand_jsons: List[dict],
                                        all_mods: Dict[str, Modification]):
        for strand, strand_json in zip(strands, strand_jsons):
            if modification_5p_key in strand_json:
                mod_name = strand_json[modification_5p_key]
                strand.modification_5p = all_mods[mod_name]
            if modification_3p_key in strand_json:
                mod_name = strand_json[modification_3p_key]
                strand.modification_3p = all_mods[mod_name]
            if modifications_int_key in strand_json:
                mod_names_by_offset = strand_json[modifications_int_key]
                for offset_str, mod_name in mod_names_by_offset.items():
                    offset = int(offset_str)
                    strand.modifications_int[offset] = all_mods[mod_name]

    @staticmethod
    def _cadnano_v2_import_find_5_end(vstrands, strand_type: str, helix_num: int, base_id: int,
                                      id_from: int,
                                      base_from: int):
        """ Routine which finds the 5' end of a strand in a cadnano v2 import. It returns the
        helix and the base of the 5' end.
        """
        id_from_before = helix_num
        base_from_before = base_id
        while not (id_from == -1 and base_from == -1):
            id_from_before = id_from
            base_from_before = base_from
            id_from, base_from, _, _ = vstrands[id_from][strand_type][base_from]
        return id_from_before, base_from_before

    @staticmethod
    def _cadnano_v2_import_find_strand_color(vstrands, strand_type: str, strand_5_end_base: int,
                                             strand_5_end_helix: int):
        """ Routines which finds the color of a cadnano v2 strand. """
        color = default_scaffold_color
        if strand_type == 'stap':
            for base_id, stap_color in vstrands[strand_5_end_helix]['stap_colors']:
                if base_id == strand_5_end_base:
                    color = Color.from_cadnano_v2_int_hex(stap_color)
                    break
        return color

    @staticmethod
    def _cadnano_v2_import_extract_deletions(skip_table, start, end):
        """ Routines which converts cadnano skips to scadnano deletions """
        to_return = []
        for base_id in range(start, end):
            if skip_table[base_id] == -1:
                to_return.append(base_id)
        return to_return

    @staticmethod
    def _cadnano_v2_import_extract_insertions(loop_table, start, end):
        """ Routines which converts cadnano skips to scadnano insertions """
        to_return = []
        for base_id in range(start, end):
            if loop_table[base_id] != 0:
                to_return.append([base_id, loop_table[base_id]])
        return to_return

    @staticmethod
    def _cadnano_v2_import_explore_domains(vstrands, seen, strand_type: str, strand_5_end_base: int,
                                           strand_5_end_helix: int):
        """Finds all domains of a cadnano v2 strand. """
        curr_helix = strand_5_end_helix
        curr_base = strand_5_end_base
        domains = []

        direction_forward = (strand_type == 'scaf' and curr_helix % 2 == 0) or (
            (strand_type == 'stap' and curr_helix % 2 == 1))
        start, end = -1, -1
        if direction_forward:
            start = curr_base
        else:
            end = curr_base

        while not (curr_helix == -1 and curr_base == -1):
            old_helix = curr_helix
            old_base = curr_base
            seen[(curr_helix, curr_base)] = True
            curr_helix, curr_base = vstrands[curr_helix][strand_type][curr_base][2:]
            # Add crossover
            # We have a crossover when we jump helix or when order is broken on same helix
            if curr_helix != old_helix or (not direction_forward and curr_base > old_base) or (
                    direction_forward and curr_base < old_base):
                if direction_forward:
                    end = old_base
                else:
                    start = old_base

                domains.append(
                    Domain(old_helix, direction_forward, min(start, end), max(start, end) + 1,
                           deletions=DNADesign._cadnano_v2_import_extract_deletions(
                               vstrands[old_helix]['skip'], start, end),
                           insertions=DNADesign._cadnano_v2_import_extract_insertions(
                               vstrands[old_helix]['loop'], start, end)))

                direction_forward = (strand_type == 'scaf' and curr_helix % 2 == 0) or (
                    (strand_type == 'stap' and curr_helix % 2 == 1))
                start, end = -1, -1
                if direction_forward:
                    start = curr_base
                else:
                    end = curr_base

        return domains

    @staticmethod
    def _cadnano_v2_import_explore_strand(vstrands, strand_type: str, seen,
                                          helix_num: int,
                                          base_id: int):
        """ Routine that will follow a cadnano v2 strand accross helices and create
            cadnano domains and strand accordingly.
        """

        seen[(helix_num, base_id)] = True
        id_from, base_from, id_to, base_to = vstrands[helix_num][strand_type][base_id]

        if (id_from, base_from, id_to, base_to) == (-1, -1, -1, -1):
            return None

        strand_5_end_helix, strand_5_end_base = DNADesign._cadnano_v2_import_find_5_end(vstrands,
                                                                                        strand_type,
                                                                                        helix_num,
                                                                                        base_id,
                                                                                        id_from,
                                                                                        base_from)
        strand_color = DNADesign._cadnano_v2_import_find_strand_color(vstrands, strand_type,
                                                                      strand_5_end_base,
                                                                      strand_5_end_helix)
        domains = DNADesign._cadnano_v2_import_explore_domains(vstrands, seen, strand_type,
                                                               strand_5_end_base,
                                                               strand_5_end_helix)
        strand = Strand(domains=domains, is_scaffold=(strand_type == 'scaf'), color=strand_color)

        return strand

    # remove quotes when Python 3.6 support dropped
    @staticmethod
    def from_cadnano_v2(directory: str = None, filename: str = None, json_dict: dict = None) -> 'DNADesign':
        """
        Creates a DNADesign from a cadnano v2 file.
        """

        if json_dict is None:
            file_path = os.path.join(directory, filename)
            f = open(file_path, 'r')
            cadnano_v2_design = json.load(f)
            f.close()
        else:
            cadnano_v2_design = json_dict

        num_bases = len(cadnano_v2_design['vstrands'][0]['scaf'])
        grid_type = Grid.square
        if num_bases % 21 == 0:
            grid_type = Grid.honeycomb

        min_row, min_col = None, None
        for cadnano_helix in cadnano_v2_design['vstrands']:
            col, row = cadnano_helix['col'], cadnano_helix['row']
            min_row = row if min_row is None else min_row
            min_col = col if min_col is None else min_col
            min_row = row if row < min_row else min_row
            min_col = col if col < min_col else min_col

        helices = OrderedDict({})
        for cadnano_helix in cadnano_v2_design['vstrands']:
            col, row = cadnano_helix['col'], cadnano_helix['row']
            num = cadnano_helix['num']
            helix = Helix(idx=num, max_offset=num_bases, grid_position=(col, row))
            helices[num] = helix

        # We do a DFS on strands
        seen = {'scaf': {}, 'stap': {}}
        strands: List[Strand] = []
        cadnano_helices = OrderedDict({})
        for cadnano_helix in cadnano_v2_design['vstrands']:
            helix_num = cadnano_helix['num']
            cadnano_helices[helix_num] = cadnano_helix

        for cadnano_helix in cadnano_v2_design['vstrands']:
            helix_num = cadnano_helix['num']
            for strand_type in ['scaf', 'stap']:
                for base_id in range(num_bases):
                    if (helix_num, base_id) in seen[strand_type]:
                        continue

                    strand = DNADesign._cadnano_v2_import_explore_strand(cadnano_helices,
                                                                         strand_type,
                                                                         seen[strand_type], helix_num,
                                                                         base_id)
                    if strand is not None:
                        strands.append(strand)

        design = DNADesign(grid=grid_type, helices=helices, strands=strands)
        design.set_helices_view_order([num for num in helices])

        return design

    def _all_modifications(self) -> Set[Modification]:
        # List of all modifications.
        mods_5p = {strand.modification_5p for strand in self.strands if
                   strand.modification_5p is not None}
        mods_3p = {strand.modification_3p for strand in self.strands if
                   strand.modification_3p is not None}
        mods_int = {mod for strand in self.strands for mod in strand.modifications_int.values()}
        return mods_5p | mods_3p | mods_int

    def strand(self, helix: int, offset: int) -> StrandBuilder:
        """Used for "literal" chained method building by calling
        :py:meth:`DNADesign.strand` to build the :any:`Strand` domain by domain, in order from 5' to 3'.
        For example

        .. code-block:: Python

            design.strand(0, 7).to(10).cross(1).to(5).cross(2).to(15)

        This creates a :any:`Strand` in this :any:`DNADesign` equivalent to

        .. code-block:: Python

            design.add_strand(Strand([
                sc.Domain(0, True, 7, 10),
                sc.Domain(1, False, 5, 10),
                sc.Domain(2, True, 5, 15),
            ]))

        Loopouts can also be included:

        .. code-block:: Python

            design.strand(0, 7).to(10).cross(1).to(5).loopout(2, 3).to(15)

        This creates a :any:`Strand` in this :any:`DNADesign` equivalent to

        .. code-block:: Python

            design.add_strand(Strand([
                sc.Domain(0, True, 7, 10),
                sc.Domain(1, False, 5, 10),
                sc.Loopout(3),
                sc.Domain(2, True, 5, 15),
            ]))

        Each call to
        :py:meth:`DNADesign.strand`,
        :py:meth:`DNADesign.cross`,
        :py:meth:`DNADesign.loopout`,
        :py:meth:`DNADesign.to`
        returns a :any:`StrandBuilder` object.

        Each call to
        :py:meth:`DNADesign.to`,
        :py:meth:`DNADesign.update_to`,
        or
        :py:meth:`DNADesign.loopout`
        modifies the :any:`DNADesign` by replacing the Strand with an updated version.

        See the documentation for :any:`StrandBuilder` for the methods available to call in this way.

        :param helix: starting :any:`Helix`
        :param offset: starting offset on `helix`
        :return: :any:`StrandBuilder` object representing the partially completed :any:`Strand`
        """
        return StrandBuilder(self, helix, offset)

    def assign_m13_to_scaffold(self, rotation: int = 5588, variant: M13Variant = M13Variant.p7249):
        """Assigns the scaffold to be the sequence of M13: :py:func:`m13` with the given `rotation`.

        Raises :any:`IllegalDNADesignError` if the number of scaffolds is not exactly 1.
        """
        scaffold = None
        num_scafs = 0
        for strand in self.strands:
            if strand.is_scaffold:
                num_scafs += 1
                if scaffold is None:
                    scaffold = strand
        if num_scafs == 0:
            raise IllegalDNADesignError(
                'Tried to assign DNA to scaffold, but there is no scaffold strand. '
                'You must set strand.is_scaffold to True for exactly one strand.')
        elif num_scafs > 1:
            raise IllegalDNADesignError(
                'Tried to assign DNA to scaffold, but there are multiple scaffold '
                'strands. You must set strand.is_scaffold to True for exactly one '
                'strand.')
        self.assign_dna(scaffold, m13(rotation, variant))

    @staticmethod
    def _get_multiple_of_x_sup_closest_to_y(x: int, y: int) -> int:
        return y if y % x == 0 else y + (x - y % x)

    @staticmethod
    def _cadnano_v2_place_strand_segment(helix_dct, domain: Domain,
                                         strand_type: str = 'scaf') -> None:
        """Converts a strand region with no crossover to cadnano v2.
        """
        # Insertions and deletions
        for deletion in domain.deletions:
            helix_dct['skip'][deletion] = -1
        for loop_where, loop_nb in domain.insertions:
            helix_dct['loop'][loop_where] = loop_nb

        start, end, forward = domain.start, domain.end, domain.forward
        strand_helix = helix_dct['num']

        for i_base in range(start, end):
            if forward:
                from_helix, from_base = strand_helix, i_base - 1
                to_helix, to_base = strand_helix, i_base + 1
            else:
                from_helix, from_base = strand_helix, i_base + 1
                to_helix, to_base = strand_helix, i_base - 1

            if i_base == start:
                if forward:
                    helix_dct[strand_type][i_base][2:] = [to_helix, to_base]
                else:
                    helix_dct[strand_type][i_base][:2] = [from_helix, from_base]
            elif i_base < end - 1:
                helix_dct[strand_type][i_base] = [from_helix, from_base, to_helix, to_base]
            else:
                if forward:
                    helix_dct[strand_type][i_base][:2] = [from_helix, from_base]
                else:
                    helix_dct[strand_type][i_base][2:] = [to_helix, to_base]
        return

    @staticmethod
    def _cadnano_v2_place_crossover(helix_from_dct: dict, helix_to_dct: dict,
                                    domain_from: Domain, domain_to: Domain,
                                    strand_type: str = 'scaf') -> None:
        """Converts a crossover to cadnano v2 format.
        Returns a conversion table from ids in the structure self.helices to helices ids
        as given by helix.idx.
        """

        helix_from = helix_from_dct['num']
        start_from, end_from, forward_from = domain_from.start, domain_from.end, domain_from.forward

        helix_to = helix_to_dct['num']
        start_to, end_to = domain_to.start, domain_to.end

        if forward_from:
            helix_from_dct[strand_type][end_from - 1][2:] = [helix_to, end_to - 1]
            helix_to_dct[strand_type][end_to - 1][:2] = [helix_from, end_from - 1]
        else:
            helix_from_dct[strand_type][start_from][2:] = [helix_to, start_to]
            helix_to_dct[strand_type][start_to][:2] = [helix_from, start_from]

    @staticmethod
    def _cadnano_v2_color_of_stap(color: Color, domain: Domain) -> List[int]:
        base_id = domain.start if domain.forward else domain.end - 1
        cadnano_color = color.to_cadnano_v2_int_hex()
        return [base_id, cadnano_color]

    def _cadnano_v2_place_strand(self, strand: Strand, dct: dict,
                                 helices_ids_reverse: Dict[int, int]) -> None:
        """Place a scadnano strand in cadnano v2.
        """
        strand_type = 'stap'
        if hasattr(strand, is_scaffold_key) and strand.is_scaffold:
            strand_type = 'scaf'

        for i, domain in enumerate(strand.domains):
            which_helix_id = helices_ids_reverse[domain.helix]
            which_helix = dct['vstrands'][which_helix_id]

            if strand_type == 'stap':
                which_helix['stap_colors'].append(self._cadnano_v2_color_of_stap(strand.color, domain))

            self._cadnano_v2_place_strand_segment(which_helix, domain, strand_type)

            if i != len(strand.domains) - 1:
                next_domain = strand.domains[i + 1]
                next_helix_id = helices_ids_reverse[next_domain.helix]
                next_helix = dct['vstrands'][next_helix_id]
                self._cadnano_v2_place_crossover(which_helix, next_helix,
                                                 domain, next_domain, strand_type)

    def _cadnano_v2_fill_blank(self, dct: dict, num_bases: int) -> dict:
        """Creates blank cadnanov2 helices in and initialized all their fields.
        """
        helices_ids_reverse = {}
        for i, helix in self.helices.items():
            helix_dct = OrderedDict()
            helix_dct['num'] = helix.idx

            if self.grid == Grid.square:
                helix_dct['row'] = helix.grid_position[1]
                helix_dct['col'] = helix.grid_position[0]

            if self.grid == Grid.honeycomb:
                helix_dct['row'], helix_dct['col'] = helix.grid_position[1], helix.grid_position[0]

            helix_dct['scaf'] = []
            helix_dct['stap'] = []
            helix_dct['loop'] = []
            helix_dct['skip'] = []

            for _ in range(num_bases):
                helix_dct['scaf'].append([-1, -1, -1, -1])
                helix_dct['stap'].append([-1, -1, -1, -1])
                helix_dct['loop'].append(0)
                helix_dct['skip'].append(0)

            helix_dct['stap_colors'] = []
            helix_dct['scafLoop'] = []
            helix_dct['stapLoop'] = []

            helices_ids_reverse[helix_dct['num']] = i
            dct['vstrands'].append(helix_dct)
        return helices_ids_reverse

    def to_cadnano_v2(self):
        """Converts the design to the cadnano v2 format.
        Please see the spec `misc/cadnano-format-specs/v2.txt` for more info on that format.
        """
        dct = OrderedDict()
        dct['vstrands'] = []

        if self.__class__ != DNADesign:
            raise ValueError(
                'Please export DNAOrigamiDesign only as we need to know which strand is the scaffold.')

        '''Figuring out the type of grid.
        In cadnano v2, all helices have the same max offset 
        called `num_bases` and the type of grid is determined as follows:
            if num_bases % 32 == 0: then we are on grid square
            if num_bases % 21 == 0: then we are on grid honey
        '''
        num_bases = 0
        for helix in self.helices.values():
            num_bases = max(num_bases, helix.max_offset)

        if self.grid == Grid.square:
            num_bases = self._get_multiple_of_x_sup_closest_to_y(32, num_bases)
        elif self.grid == Grid.honeycomb:
            num_bases = self._get_multiple_of_x_sup_closest_to_y(21, num_bases)
        else:
            raise NotImplementedError('We can export to cadnano v2 `square` and `honeycomb` grids only.')

        '''Figuring out if helices numbers have good parity.
        In cadnano v2, only even helices have the scaffold go forward, only odd helices
        have the scaffold go backward.

        '''
        for strand in self.strands:
            if hasattr(strand, is_scaffold_key) and strand.is_scaffold:
                for domain in strand.domains:
                    if type(domain) == Loopout:
                        raise ValueError(
                            'We cannot handle designs with Loopouts as it is not a cadnano v2 concept')
                    if domain.helix % 2 != int(not domain.forward):
                        raise ValueError('We can only convert designs where even helices have the scaffold \
                                              going forward and odd helices have the scaffold going backward see the spec v2.txt Note 4. {}'.format(
                            domain))

        '''Filling the helices with blank.
        '''
        helices_ids_reverse = self._cadnano_v2_fill_blank(dct, num_bases)
        '''Putting the scaffold in place.
        '''

        for strand in self.strands:
            self._cadnano_v2_place_strand(strand, dct, helices_ids_reverse)

        return dct

    def _set_and_check_helices_view_order(self):
        identity = list(self.helices.keys())
        if self.helices_view_order is None:
            self.helices_view_order = identity
        self._check_helices_view_order_is_bijection()

    def set_helices_view_order(self, helices_view_order: List[int]):
        self.helices_view_order = helices_view_order
        self._check_helices_view_order_is_bijection()

    def _check_helices_view_order_is_bijection(self):
        if not (sorted(self.helices_view_order) == sorted(self.helices.keys())):
            raise IllegalDNADesignError(
                f"The specified helices view order: {self.helices_view_order}\n "
                f"is not a bijection on helices indices: {self.helices_view_order} {self.helices.keys()}.")

    def _set_helices_idxs(self):
        for idx, helix in self.helices.items():
            if helix.idx is None:
                helix.idx = idx

    def _set_helices_grid_positions(self):
        for idx, helix in self.helices.items():
            if helix.grid_position is None:
                helix.grid_position = helix.default_grid_position()

    def _set_helices_min_max_offsets(self, update: bool):
        """update = whether to overwrite existing Helix.max_offset and Helix.min_offset.
        Don't do this when DNADesign is first created, but do it later when updating."""
        for helix in self.helices.values():

            if update or helix.max_offset is None:
                max_offset = None if len(helix.domains) == 0 else helix.domains[0].end
                for domain in helix.domains:
                    max_offset = max(max_offset, domain.end)
                helix.max_offset = max_offset

            if update or helix.min_offset is None:
                min_offset = None if len(helix.domains) == 0 else helix.domains[0].start
                for domain in helix.domains:
                    min_offset = min(min_offset, domain.start)
                if min_offset is None or min_offset > 0:
                    min_offset = 0
                helix.min_offset = min_offset

    def set_default_idt(self, use_default_idt: bool = True):
        """If ``True``, sets :py:data:`Strand.use_default_idt` to ``True`` for every :any:`Strand` in this
        :any:`DNADesign` and calls :py:meth:`Strand.set_default_idt` on each of them to assign a
        default idt field.

        If ``False``, removes IDT field from each :any:`Strand`."""
        for strand in self.strands:
            strand.set_default_idt(use_default_idt)

    def strands_starting_on_helix(self, helix: int) -> List[Strand]:
        """Return list of :any:`Strand`'s that begin (have their 5' end)
        on the :any:`Helix` with index `helix`."""
        return [strand for strand in self.strands if strand.domains[0].helix == helix]

    def strands_ending_on_helix(self, helix: int) -> List[Strand]:
        """Return list of :any:`Strand`'s that finish (have their 3' end)
        on the :any:`Helix` with index `helix`."""
        return [strand for strand in self.strands if strand.domains[-1].helix == helix]

    def _check_legal_design(self):
        self._check_helix_offsets()
        self._check_strands_reference_helices_legally()
        self._check_loopouts_not_consecutive_or_singletons_or_zero_length()
        self._check_strands_overlap_legally()

    # TODO: come up with reasonable default behavior when no strands are on helix and max_offset not given
    def _check_helix_offsets(self):
        for helix in self.helices.values():
            if helix.min_offset is not None \
                    and helix.max_offset is not None \
                    and helix.min_offset >= helix.max_offset:
                err_msg = f'for helix {helix.idx}, ' \
                          f'helix.min_offset = {helix.min_offset} must be strictly less than ' \
                          f'helix.max_offset = {helix.max_offset}'
                raise IllegalDNADesignError(err_msg)

    def _check_strands_overlap_legally(self, domain_to_check: Domain[DomainLabel] = None):
        """If `Domain_to_check` is None, check all.
        Otherwise only check pairs where one is domain_to_check."""

        def err_msg(d1: Domain[DomainLabel], d2: Domain[DomainLabel], h_idx: int) -> str:
            return f"two domains overlap on helix {h_idx}: " \
                   f"\n{d1}\n  and\n{d2}\n  but have the same direction"

        # ensure that if two strands overlap on the same helix,
        # they point in opposite directions
        for helix_idx, domains in enumerate(helix.domains for helix in self.helices.values()):
            if domain_to_check is not None and domain_to_check.helix != helix_idx:
                # TODO: if necessary, we can be more efficient by only checking this one domain
                continue

            if len(domains) == 0:
                continue

            # check all consecutive domains on the same helix, sorted by start/end indices
            offsets_data = []
            for domain in domains:
                offsets_data.append((domain.start, True, domain))
                offsets_data.append((domain.end, False, domain))
            offsets_data.sort(key=lambda offset_data: offset_data[0])

            current_domains: List[Domain[DomainLabel]] = []
            for offset, is_start, domain in offsets_data:
                if is_start:
                    if len(current_domains) >= 2:
                        if offset >= current_domains[1].end:
                            del current_domains[1]
                    if len(current_domains) >= 1:
                        if offset >= current_domains[0].end:
                            del current_domains[0]
                    current_domains.append(domain)
                    if len(current_domains) > 2:
                        domain0, domain1, domain2 = current_domains[0:3]
                        for d_first, d_second in [(domain0, domain1), (domain1, domain2), (domain0, domain2)]:
                            if d_first.forward == d_second.forward:
                                raise IllegalDNADesignError(err_msg(d_first, d_second, helix_idx))
                        raise AssertionError(
                            f"since current_domains = {current_domains} has at least three domains, "
                            f"I expected to find a pair of illegally overlapping domains")
                    elif len(current_domains) == 2:
                        d_first, d_second = current_domains
                        if d_first.forward == d_second.forward:
                            raise IllegalDNADesignError(err_msg(d_first, d_second, helix_idx))

    def _check_loopouts_not_consecutive_or_singletons_or_zero_length(self):
        for strand in self.strands:
            DNADesign._check_loopout_not_singleton(strand)
            DNADesign._check_two_consecutive_loopouts(strand)
            DNADesign._check_loopouts_length(strand)

    @staticmethod
    def _check_loopout_not_singleton(strand: Strand):
        if len(strand.domains) == 1 and strand.first_domain().is_loopout():
            raise StrandError(strand, 'strand cannot have a single Loopout as its only domain')

    @staticmethod
    def _check_two_consecutive_loopouts(strand: Strand):
        for domain1, domain2 in _pairwise(strand.domains):
            if domain1.is_loopout() and domain2.is_loopout():
                raise StrandError(strand, 'cannot have two consecutive Loopouts in a strand')

    @staticmethod
    def _check_loopouts_length(strand: Strand):
        for loopout in strand.domains:
            if loopout.is_loopout() and loopout.length <= 0:
                raise StrandError(strand, f'loopout length must be positive but is {loopout.length}')

    def _check_strands_reference_helices_legally(self):
        # ensure each strand refers to an existing helix
        for strand in self.strands:
            self._check_strand_references_legal_helices(strand)
            self._check_strand_has_legal_offsets_in_helices(strand)

    def _check_strand_references_legal_helices(self, strand: Strand):
        for domain in strand.domains:
            if domain.is_domain() and domain.helix not in self.helices:
                err_msg = f"domain {domain} refers to nonexistent Helix index {domain.helix}; " \
                          f"here is the list of valid helices: {self._helices_to_string()}"
                raise StrandError(strand, err_msg)

    def _check_strand_has_legal_offsets_in_helices(self, strand: Strand):
        for domain in strand.domains:
            if domain.is_domain():
                helix = self.helices[domain.helix]
                if domain.start < helix.min_offset:
                    err_msg = f"domain {domain} has start offset {domain.start}, " \
                              f"beyond the end of " \
                              f"Helix {domain.helix} that has min_offset = {helix.min_offset}"
                    raise StrandError(strand, err_msg)
                if domain.end > helix.max_offset:
                    err_msg = f"domain {domain} has end offset {domain.end}, " \
                              f"beyond the end of " \
                              f"Helix {domain.helix} that has max_offset = {helix.max_offset}"
                    raise StrandError(strand, err_msg)

        # ensure helix_idx's are never negative twice in a row
        for domain1, domain2 in _pairwise(strand.domains):
            if domain1.is_loopout() and domain2.is_loopout():
                err_msg = f"Loopouts {domain1} and {domain2} are consecutive on strand {strand}. " \
                          f"At least one of any consecutive pair must be a Domain, not a Loopout."
                raise StrandError(strand, err_msg)

    def set_helix_idx(self, old_idx: int, new_idx: int):
        if new_idx in self.helices:
            raise IllegalDNADesignError(f'cannot assign idx {new_idx} to helix {old_idx}; '
                                        'another helix already has that index')
        helix: Helix = self.helices[old_idx]
        del self.helices[old_idx]
        self.helices[new_idx] = helix
        helix.idx = new_idx
        for domain in helix.domains:
            domain.helix = new_idx

    def domain_at(self, helix: int, offset: int, forward: bool):
        """
        Return :any:`Domain` that overlaps `offset` on helix with idx `helix` and has
        :py:data:`Domain.forward` = ``True``, or ``None`` if there is no such :any:`Domain`.

        :param helix: TODO
        :param offset: TODO
        :param forward: TODO
        :return: TODO
        """
        for domain in self.domains_at(helix, offset):
            if domain.forward == forward:
                return domain
        return None

    def domains_at(self, helix: int, offset: int) -> List[Domain[DomainLabel]]:
        """Return list of :any:`Domain`'s that overlap `offset` on helix with idx `helix`.

        If constructed properly, this list should have 0, 1, or 2 elements."""
        domains_on_helix = self.helices[helix].domains
        # TODO: replace this with a faster algorithm using binary search
        domains_on_helix = [domain for domain in domains_on_helix if
                            domain.contains_offset(offset)]
        if len(domains_on_helix) not in [0, 1, 2]:
            raise AssertionError(f'There should be at most 2 domains on helix {helix}, '
                                 f'but there are {len(domains_on_helix)}:\n{domains_on_helix}')
        return domains_on_helix

    # TODO: add_strand and insert_domain should check for existing deletions/insertion parallel strands
    def add_strand(self, strand: Strand):
        """Add `strand` to this design."""
        self._check_strand_references_legal_helices(strand)
        self.strands.append(strand)
        for domain in strand.domains:
            if domain.is_domain():
                self.helices[domain.helix].domains.append(domain)
                self._check_strands_overlap_legally(domain_to_check=domain)
        if self.automatically_assign_color:
            self._assign_color_to_strand(strand)

    def remove_strand(self, strand: Strand):
        """Remove `strand` from this design."""
        self.strands.remove(strand)
        for domain in strand.domains:
            if domain.is_domain():
                self.helices[domain.helix].domains.remove(domain)

    def append_domain(self, strand: Strand, domain: Union[Domain[DomainLabel], Loopout]):
        """
        Same as :any:`DNADesign.insert_domain`, but inserts at end.

        :param strand: strand to append `domain` to
        :param domain: :any:`Domain` or :any:`Loopout` to append to :any:`Strand`
        """
        self.insert_domain(strand, len(strand.domains), domain)

    def insert_domain(self, strand: Strand, order: int, domain: Union[Domain[DomainLabel], Loopout]):
        """Insert `Domain` into `strand` at index given by `order`. Uses same indexing as Python lists,
        e.g., ``design.insert_domain(strand, domain, 0)``
        inserts ``domain`` as the new first :any:`Domain`."""
        if domain.is_domain() and domain.helix not in self.helices:
            err_msg = f"domain {domain} refers to nonexistent Helix index {domain.helix}; " \
                      f"here is the list of valid helices: {self._helices_to_string()}"
            raise StrandError(strand, err_msg)

        assert strand in self.strands
        strand.insert_domain(order, domain)
        self._check_strand_references_legal_helices(strand)
        self._check_loopouts_not_consecutive_or_singletons_or_zero_length()
        if domain.is_domain():
            self.helices[domain.helix].domains.append(domain)
            self._check_strands_overlap_legally(domain_to_check=domain)

    def remove_domain(self, strand: Strand, domain: Union[Domain[DomainLabel], Loopout]):
        """Remove `Domain` from `strand`."""
        assert strand in self.strands
        strand.remove_domain(domain)
        if domain.is_domain():
            self.helices[domain.helix].domains.remove(domain)

    def _build_domains_on_helix_lists(self):
        for helix in self.helices.values():
            helix._domains = []
        for strand in self.strands:
            for domain in strand.domains:
                if domain.is_domain():
                    if domain.helix in self.helices:
                        self.helices[domain.helix].domains.append(domain)
                    else:
                        msg = f"domain's helix is {domain.helix} but no helix has that index; here " \
                              f"is the list of helix indices: {self._helices_to_string()}"
                        raise StrandError(strand=strand, the_cause=msg)

    def _helices_to_string(self):
        return ', '.join(map(str, self.helices.keys()))

    def to_json(self, suppress_indent: bool = True) -> str:
        """Return string representing this DNADesign, suitable for reading by scadnano if written to
        a JSON file ending in extension .dna"""
        # if isinstance(self, DNAOrigamiDesign):
        #     scaf = None
        #     for strand in self.strands:
        #         if strand.is_scaffold == True:
        #             scaf = strand
        #             break
        #     if self.scaffold is None:
        #         msg = 'No scaffold specified for DNADesign. You can delay assigning the scaffold ' \
        #               'until after creating the DNADesign object, but you must assign a scaffold ' \
        #               'using the method Strand.set_scaffold() before calling to_json().'
        #         if scaf is not None:
        #             msg += f'There is a strand marked as a scaffold. Try calling set_scaffold with it as ' \
        #                    f'a parameter:\n{scaf}'
        #         raise IllegalDNADesignError(msg)
        return _json_encode(self, suppress_indent)

    # TODO: create version of add_deletion and add_insertion that simply changes the major tick distance
    #  on the helix at that position, as well as updating the end offset of the domain (and subsequent
    #  domains on the same helix)

    def add_deletion(self, helix: int, offset: int):
        """Adds a deletion to every :class:`scadnano.Strand` at the given helix and base offset."""
        domains = self.domains_at(helix, offset)
        if len(domains) == 0:
            raise IllegalDNADesignError(f"no domains are at helix {helix} offset {offset}")
        for domain in domains:
            if domain.contains_offset(offset):
                domain.deletions.append(offset)

    def add_insertion(self, helix: int, offset: int, length: int):
        """Adds an insertion with the given length to every :class:`scadnano.Strand`
        at the given helix and base offset, with the given length."""
        domains = self.domains_at(helix, offset)
        if len(domains) == 0:
            raise IllegalDNADesignError(f"no domains are at helix {helix} offset {offset}")
        for domain in domains:
            if domain.contains_offset(offset):
                domain.insertions.append((offset, length))

    def set_start(self, domain: Domain[DomainLabel], start: int):
        """Sets ``Domain.start`` to `start`."""
        assert domain in (domain for strand in self.strands for domain in strand.domains)
        domain.set_start(start)
        self._check_strands_overlap_legally(domain)

    def set_end(self, domain: Domain[DomainLabel], end: int):
        """Sets ``Domain.end`` to `end`."""
        assert domain in (domain for strand in self.strands for domain in strand.domains)
        domain.set_end(end)
        self._check_strands_overlap_legally(domain)

    def move_strand_offsets(self, delta: int):
        """Moves all strands backward (if `delta` < 0) or forward (if `delta` > 0) by `delta`."""
        for strand in self.strands:
            for domain in strand.domains:
                domain.start += delta
                domain.end += delta
        self._check_strands_overlap_legally()

    def move_strands_on_helices(self, delta: int):
        """Moves all strands up (if `delta` < 0) or down (if `delta` > 0) by the number of helices given by
        `delta`."""
        for strand in self.strands:
            for domain in strand.domains:
                domain.helix += delta
        self._check_strands_reference_helices_legally()

    def assign_dna(self, strand: Strand, sequence: str, assign_complement: bool = True,
                   domain: Union[Domain, Loopout] = None):
        """
        Assigns `sequence` as DNA sequence of `strand`.

        If any :class:`scadnano.Strand` is bound to `strand`,
        it is assigned the reverse Watson-Crick complement of the relevant portion,
        and any remaining portions of the other strand that have not already been assigned a DNA sequence
        are assigned to be the symbol :py:data:`DNA_base_wildcard`.

        Before assigning, `sequence` is first forced to be the same length as `strand`
        as follows:
        If `sequence` is longer, it is truncated.
        If `sequence` is shorter, it is padded with :py:data:`DNA_base_wildcard`'s.

        All whitespace in `sequence` is removed, and lowercase bases
        'a', 'c', 'g', 't' are converted to uppercase.

        :param strand: :any:`Strand` to assign DNA sequence to
        :param sequence: string of DNA bases to assign
        :param assign_complement: whether to assign the complement DNA sequence to any :any:`Strand` that
            is bound to this one (default True)
        :param domain: :any:`Domain` on `strand` to assign. If ``None``, then the whole :any:`Strand` is
            given a DNA sequence. Otherwise, only `domain` is assigned, and the rest of the :any:`Domain`'s
            on `strand` are left alone (either keeping their DNA sequence, or being assigned
            :py:const:`DNA_case_wildcard` if no DNA sequence was previously assigned.)
            If `domain` is specified, then ``len(sequence)`` must be least than or equal to the number
            of bases on `domain`. (i.e., ``domain.dna_length()``)
        """
        start = 0
        if domain is not None:
            pos = strand.domains.index(domain)
            start = sum(prev_dom.dna_length() for prev_dom in strand.domains[:pos])
            if domain.dna_length() < len(sequence):
                raise IllegalDNADesignError(f'cannot assign sequence {sequence} to strand domain '
                                            f'\n{domain}\n'
                                            f'The number of bases on the domain is {domain.dna_length()} '
                                            f'but the length of the sequence is {len(sequence)}. The '
                                            f'length of the sequence must be at most the numebr of bases '
                                            f'on the domain.')
        padded_sequence = _pad_and_remove_whitespace_and_uppercase(sequence, strand, start)
        if strand is None:
            raise IllegalDNADesignError('strand cannot be None to assign DNA to it')
        if strand not in self.strands:
            raise StrandError(strand, 'strand is not in the given DNADesign')

        if strand.dna_sequence is None:
            merged_sequence = padded_sequence
        else:
            try:
                merged_sequence = _string_merge_wildcard(strand.dna_sequence, padded_sequence,
                                                         DNA_base_wildcard)
            except ValueError:
                first_domain = strand.first_domain()
                msg = f'strand starting at helix {first_domain.helix}, offset {first_domain.offset_5p()} has ' \
                      f'length ' \
                      f'{strand.dna_length()} and already has a DNA sequence assignment of length ' \
                      f'{len(strand.dna_sequence)}, which is \n' \
                      f'{strand.dna_sequence}, ' \
                      f'but you tried to assign a different sequence of length {len(padded_sequence)} to ' \
                      f'it, which is\n{padded_sequence}.'
                raise IllegalDNADesignError(msg)

        strand.set_dna_sequence(merged_sequence)

        if not assign_complement:
            return

        for other_strand in self.strands:
            # note that possibly strand==other_strand; it might bind to itself at some point and we want to
            # allow a partial assignment to one domain to automatically assign the complement to the
            # bound domain.
            # However, if there are no wildcards in the assigned sequence we can safely skip strand.
            if strand == other_strand and DNA_base_wildcard not in strand.dna_sequence:
                continue
            if other_strand.overlaps(strand):
                # we do this even if other_strand has a complete DNA sequence,
                # because we get complementarity checks this way
                other_strand.assign_dna_complement_from(strand)

    def to_idt_bulk_input_format(self, delimiter: str = ',', warn_duplicate_name: bool = False,
                                 warn_on_non_idt_strands: bool = False,
                                 export_non_modified_strand_version: bool = False) -> str:
        """Return string that is written to the file in the method
        :py:meth:`DNADesign.write_idt_bulk_input_file`.

        `delimiter` is the symbol to delimit the four IDT fields name,sequence,scale,purification.

        `warn_duplicate_name` if ``True`` prints a warning when two different :any:`Strand`'s have the same
        :py:attr:`IDTField.name` and the same :any:`Strand.dna_sequence`. An :any:`IllegalDNADesignError` is
        raised (regardless of the value of this parameter)
        if two different :any:`Strand`'s have the same name but different sequences, IDT scales, or IDT
        purifications.

        `warn_on_non_idt_strands` specifies whether to print a warning for strands that lack the field
        :any:`Strand.idt`. Such strands will not be part of the output. No warning is ever issued for the
        scaffold (regardless of the value of the parameter `warn_on_non_idt_strands`).

        `export_non_modified_strand_version` specifies whether, for each strand that has modifications,
        to also output a version of the strand with no modifications, but otherwise having the same data.
        """
        added_strands = self._idt_strands(warn_duplicate_name, warn_on_non_idt_strands,
                                          export_non_modified_strand_version)

        idt_lines = [
            delimiter.join(
                [strand.idt.name, strand.idt_dna_sequence(), strand.idt.scale, strand.idt.purification])
            for strand in added_strands.values()]

        idt_string = '\n'.join(idt_lines)
        return idt_string

    def _idt_strands(self, warn_duplicate_name: bool, warn_on_non_idt_strands: bool,
                     export_non_modified_strand_version: bool = False) -> Dict[str, Strand]:
        added_strands: Dict[str, Strand] = {}  # dict: name -> strand
        for strand in self.strands:
            if strand.idt is not None:
                name = strand.idt.name
                if name in added_strands:
                    existing_strand = added_strands[name]
                    assert existing_strand.idt.name == name
                    domain = strand.first_domain()
                    existing_domain = existing_strand.first_domain()
                    if strand.dna_sequence != existing_strand.dna_sequence:
                        raise IllegalDNADesignError(
                            f'two strands with same IDT name {name} but different sequences:\n'
                            f'  strand 1: helix {domain.helix}, 5\' end at offset {domain.offset_5p()}, '
                            f'sequence: {strand.dna_sequence}\n'
                            f'  strand 2: helix {existing_domain.helix}, 5\' end at offset '
                            f'{existing_domain.offset_5p()}, '
                            f'sequence: {existing_strand.dna_sequence}\n')
                    elif strand.idt.scale != existing_strand.idt.scale:
                        raise IllegalDNADesignError(
                            f'two strands with same IDT name {name} but different IDT scales:\n'
                            f'  strand 1: helix {domain.helix}, 5\' end at offset {domain.offset_5p()}, '
                            f'scale: {strand.idt.scale}\n'
                            f'  strand 2: helix {existing_domain.helix}, 5\' end at offset '
                            f'{existing_domain.offset_5p()}, '
                            f'scale: {existing_strand.idt.scale}\n')
                    elif strand.idt.purification != existing_strand.idt.purification:
                        raise IllegalDNADesignError(
                            f'two strands with same IDT name {name} but different purifications:\n'
                            f'  strand 1: helix {domain.helix}, 5\' end at offset {domain.offset_5p()}, '
                            f'purification: {strand.idt.purification}\n'
                            f'  strand 2: helix {existing_domain.helix}, 5\' end at offset '
                            f'{existing_domain.offset_5p()}, '
                            f'purification: {existing_strand.idt.purification}\n')
                    elif warn_duplicate_name:
                        print(
                            f'WARNING: two strands with same IDT name {name}:\n'
                            f'  strand 1: helix {domain.helix}, 5\' end at offset {domain.offset_5p()}\n'
                            f'  strand 2: helix {existing_domain.helix}, 5\' end at offset '
                            f'{existing_domain.offset_5p()}\n')
                added_strands[name] = strand
                if export_non_modified_strand_version:
                    added_strands[name + '_nomods'] = strand.unmodified_version()
            elif warn_on_non_idt_strands and not strand.is_scaffold:
                print(f"WARNING: strand with 5' end at (helix, offset) "
                      f"({strand.first_domain().helix}, {strand.first_domain().offset_5p()}) "
                      f"does not have a field idt, so will not be part of IDT output.")
        return added_strands

    def write_idt_bulk_input_file(self, directory: str = '.', filename: str = None, extension: str = None,
                                  delimiter: str = ',',
                                  warn_duplicate_name: bool = True, warn_on_non_idt_strands: bool = True,
                                  export_non_modified_strand_version: bool = False):
        """Write ``.idt`` text file encoding the strands of this :any:`DNADesign` with the field
        :any:`Strand.idt`, suitable for pasting into the "Bulk Input" field of IDT
        (Integrated DNA Technologies, Coralville, IA, https://www.idtdna.com/),
        with the output file having the same name as the running script but with ``.py`` changed to ``.idt``,
        unless `filename` is explicitly specified.
        For instance, if the script is named ``my_origami.py``,
        then the sequences will be written to ``my_origami.idt``.
        If `filename` is not specified but `extension` is, then that extension is used instead of ``idt``.
        At least one of `filename` or `extension` must be ``None``.

        `directory` specifies a directory in which to place the file, either absolute or relative to
        the current working directory. Default is the current working directory.

        `delimiter` is the symbol to delimit the four IDT fields name,sequence,scale,purification.

        `warn_duplicate_name` if ``True`` prints a warning when two different :any:`Strand`'s have the same
        :py:attr:`IDTField.name` and the same :any:`Strand.dna_sequence`. An :any:`IllegalDNADesignError` is
        raised (regardless of the value of this parameter)
        if two different :any:`Strand`'s have the same name but different sequences, IDT scales, or IDT
        purifications.

        `warn_on_non_idt_strands` specifies whether to print a warning for strands that lack the field
        :any:`Strand.idt`. Such strands will not be output into the file.

        The string written is that returned by :meth:`DNADesign.to_idt_bulk_input_format`.
        """
        contents = self.to_idt_bulk_input_format(delimiter, warn_duplicate_name, warn_on_non_idt_strands,
                                                 export_non_modified_strand_version)
        if extension is None:
            extension = 'idt'
        _write_file_same_name_as_running_python_script(contents, extension, directory, filename)

    def write_idt_plate_excel_file(self, directory: str = '.', filename: str = None,
                                   warn_duplicate_name: bool = False, warn_on_non_idt_strands: bool = False,
                                   use_default_plates: bool = False, warn_using_default_plates: bool = True,
                                   plate_type: PlateType = PlateType.wells96,
                                   export_non_modified_strand_version: bool = False):
        """Write ``.xls`` (Microsoft Excel) file encoding the strands of this :any:`DNADesign` with the field
        :py:data:`Strand.idt`, suitable for uploading to IDT
        (Integrated DNA Technologies, Coralville, IA, https://www.idtdna.com/)
        to describe a 96-well or 384-well plate
        (https://www.idtdna.com/site/order/plate/index/dna/),
        with the output file having the same name as the running script but with ``.py`` changed to ``.xls``,
        unless `filename` is explicitly specified.
        For instance, if the script is named ``my_origami.py``,
        then the sequences will be written to ``my_origami.xls``.

        `directory` specifies a directory in which to place the file, either absolute or relative to
        the current working directory. Default is the current working directory.

        `warn_duplicate_name` if ``True`` prints a warning when two different :any:`Strand`'s have the same
        :py:attr:`IDTField.name` and the same :any:`Strand.dna_sequence`. An :any:`IllegalDNADesignError` is
        raised (regardless of the value of this parameter)
        if two different :any:`Strand`'s have the same name but different sequences, IDT scales, or IDT
        purifications.

        `warn_on_non_idt_strands` specifies whether to print a warning for strands that lack the field
        :py:data:`Strand.idt`. Such strands will not be output into the file. No warning is ever issued
        for the scaffold (regardless of the value of the parameter `warn_on_non_idt_strands`).

        `warn_using_default_plates` specifies whether to print a warning for strands whose
        :py:data:`Strand.idt` have the fields

        `plate_type` is a :any:`PlateType` specifying whether to use a 96-well plate or a 384-well plate
        if the `use_default_plates` parameter is ``True``.
        Ignored if `use_default_plates` is ``False``, because in that case the wells are explicitly set
        by the user, who is free to use coordinates for either plate type.
        """

        idt_strands = list(self._idt_strands(warn_duplicate_name, warn_on_non_idt_strands,
                                             export_non_modified_strand_version).values())

        if not use_default_plates:
            self._write_plates_assuming_explicit_in_each_strand(directory, filename, idt_strands)
        else:
            self._write_plates_default(directory=directory, filename=filename, idt_strands=idt_strands,
                                       plate_type=plate_type,
                                       warn_using_default_plates=warn_using_default_plates)

    def _write_plates_assuming_explicit_in_each_strand(self, directory: str, filename: str,
                                                       idt_strands: List[Strand]):
        plates = list({strand.idt.plate for strand in idt_strands if strand.idt is not None if
                       strand.idt.plate is not None})
        if len(plates) == 0:
            raise ValueError('Cannot write a a plate file since no plate data exists in any Strands '
                             'in the design.\n'
                             'Set the option use_default_plates=True in '
                             "DNADesign.write_idt_plate_excel_file\nif you don't want to enter plate "
                             'and well positions for each Strand you wish to write to the Excel file.')
        plates.sort()
        filename_plate, workbook = self._setup_excel_file(directory, filename)
        for plate in plates:
            worksheet = self._add_new_excel_plate_sheet(plate, workbook)

            strands_in_plate = [strand for strand in idt_strands if
                                strand.idt is not None and strand.idt.plate == plate]

            strands_in_plate.sort(key=lambda s: (int(s.idt.well[1:]), s.idt.well[0]))

            for row, strand in enumerate(strands_in_plate):
                worksheet.write(row + 1, 0, strand.idt.well)
                worksheet.write(row + 1, 1, strand.idt.name)
                worksheet.write(row + 1, 2, strand.idt_dna_sequence())

            workbook.save(filename_plate)

    @staticmethod
    def _add_new_excel_plate_sheet(plate_name: str, workbook):
        worksheet = workbook.add_sheet(plate_name)
        worksheet.write(0, 0, 'Well Position')
        worksheet.write(0, 1, 'Name')
        worksheet.write(0, 2, 'Sequence')
        return worksheet

    @staticmethod
    def _setup_excel_file(directory, filename):
        import xlwt
        plate_extension = f'xls'
        if filename is None:
            filename_plate = _get_filename_same_name_as_running_python_script(
                directory, plate_extension, filename)
        else:
            filename_plate = _create_directory_and_set_filename(directory, filename)
        workbook = xlwt.Workbook()
        return filename_plate, workbook

    def _write_plates_default(self, directory: str, filename: str, idt_strands: List[Strand],
                              plate_type: PlateType = PlateType.wells96,
                              warn_using_default_plates: bool = True):
        plate_coord = _PlateCoordinate(plate_type=plate_type)
        plate = 1
        excel_row = 1
        filename_plate, workbook = self._setup_excel_file(directory, filename)
        worksheet = self._add_new_excel_plate_sheet(f'plate{plate}', workbook)

        for strand in idt_strands:
            if warn_using_default_plates and strand.idt.plate is not None:
                print(
                    f"WARNING: strand {strand} has plate entry {strand.idt.plate}, which is being ignored "
                    f"since we are using default plate/well addressing")
            if warn_using_default_plates and strand.idt.well is not None:
                print(
                    f"WARNING: strand {strand} has well entry {strand.idt.well}, which is being ignored "
                    f"since we are using default plate/well addressing")

            well = plate_coord.well()
            worksheet.write(excel_row, 0, well)
            worksheet.write(excel_row, 1, strand.idt.name)
            worksheet.write(excel_row, 2, strand.idt_dna_sequence())
            plate_coord.increment()
            if plate != plate_coord.plate():
                workbook.save(filename_plate)
                plate = plate_coord.plate()
                worksheet = self._add_new_excel_plate_sheet(f'plate{plate}', workbook)
                excel_row = 1
            else:
                excel_row += 1

        workbook.save(filename_plate)

    def write_scadnano_file(self, directory: str = '.', filename: str = None, extension: str = None):
        """Write ``.dna`` file representing this :any:`DNADesign`, suitable for reading by scadnano,
        with the output file having the same name as the running script but with ``.py`` changed to ``.dna``,
        unless `filename` is explicitly specified.
        For instance, if the script is named ``my_origami.py``,
        then the design will be written to ``my_origami.dna``.
        If `extension` is specified (but `filename` is not), then the design will be written to
        ``my_origami.<extension>``

        `directory` specifies a directory in which to place the file, either absolute or relative to
        the current working directory. Default is the current working directory.

        The string written is that returned by :meth:`DNADesign.to_json`.

        :param directory: directory in which to put file (default: current)
        :param filename: filename (default: name of script with .py replaced by .dna).
            Mutually exclusive with `extension`
        :param extension: extension for filename (default: .dna)
            Mutually exclusive with `filename`
        """
        contents = self.to_json()
        if filename is not None and extension is not None:
            raise ValueError('at least one of filename or extension must be None')
        if extension is None:
            extension = 'dna'
        _write_file_same_name_as_running_python_script(contents, extension, directory, filename)

    def export_cadnano_v2(self, directory: str = '.', filename=None):
        """Write ``.json`` file representing this :any:`DNADesign`, suitable for reading by cadnano v2,
        with the output file having the same name as the running script but with ``.py`` changed to ``.json``,
        unless `filename` is explicitly specified.
        For instance, if the script is named ``my_origami.py``,
        then the design will be written to ``my_origami.json``.

        `directory` specifies a directory in which to place the file, either absolute or relative to
        the current working directory. Default is the current working directory.

        The string written is that returned by :meth:`DNADesign.to_cadnano_v2`.
        """
        content_serializable = OrderedDict({})
        content_serializable['name'] = _get_filename_same_name_as_running_python_script(directory, 'json',
                                                                                        filename)
        content_serializable_final = self.to_cadnano_v2()
        content_serializable.update(content_serializable_final)

        encoder = _SuppressableIndentEncoder
        contents = json.dumps(content_serializable, cls=encoder, indent=2)

        _write_file_same_name_as_running_python_script(contents, 'json', directory, filename)

    def add_nick(self, helix: int, offset: int, forward: bool):
        """Add nick to :any:`Domain` on :any:`Helix` with index `helix`,
        in direction given by `forward`, at offset `offset`. The two :any:`Domain`'s created by this nick
        will have 5'/3' ends at offsets `offset` and `offset-1`.

        For example, if there is a :any:`Domain` with
        :py:data:`Domain.helix` = ``0``,
        :py:data:`Domain.forward` = ``True``,
        :py:data:`Domain.start` = ``0``,
        :py:data:`Domain.end` = ``10``,
        then calling ``add_nick(helix=0, offset=5, forward=True)`` will split it into two :any:`Domain`'s,
        with one domains having the fields
        :py:data:`Domain.helix` = ``0``,
        :py:data:`Domain.forward` = ``True``,
        :py:data:`Domain.start` = ``0``,
        :py:data:`Domain.end` = ``5``,
        (recall that :py:data:`Domain.end` is exclusive, meaning that the largest offset on this
        :any:`Domain` is 4 = ``offset-1``)
        and the other domain having the fields
        :py:data:`Domain.helix` = ``0``,
        :py:data:`Domain.forward` = ``True``,
        :py:data:`Domain.start` = ``5``,
        :py:data:`Domain.end` = ``10``.
        """
        for domain_to_remove in self.domains_at(helix, offset):
            if domain_to_remove.forward == forward:
                break
        else:
            raise IllegalDNADesignError(f'no domain at helix {helix} in direction '
                                        f'{"forward" if forward else "reverse"} at offset {offset}')
        strand = domain_to_remove.strand()
        domains = strand.domains
        order = domains.index(domain_to_remove)
        domains_before = domains[:order]
        domains_after = domains[order + 1:]
        domain_left = Domain(helix, forward, domain_to_remove.start, offset)
        domain_right = Domain(helix, forward, offset, domain_to_remove.end)

        if domain_to_remove.forward:
            domain_to_add_before = domain_left
            domain_to_add_after = domain_right
        else:
            domain_to_add_before = domain_right
            domain_to_add_after = domain_left

        if strand.dna_sequence:
            dna_sequence_before = ''.join(domain.dna_sequence() for domain in domains_before)
            dna_sequence_after = ''.join(domain.dna_sequence() for domain in domains_after)
            dna_sequence_on_domain_left = domain_to_remove.dna_sequence_in(
                domain_to_remove.start,
                offset - 1)
            dna_sequence_on_domain_right = domain_to_remove.dna_sequence_in(offset,
                                                                            domain_to_remove.end - 1)
            if domain_to_remove.forward:
                dna_sequence_on_domain_before = dna_sequence_on_domain_left
                dna_sequence_on_domain_after = dna_sequence_on_domain_right
            else:
                dna_sequence_on_domain_before = dna_sequence_on_domain_right
                dna_sequence_on_domain_after = dna_sequence_on_domain_left
            dna_sequence_before_whole = dna_sequence_before + dna_sequence_on_domain_before
            dna_sequence_after_whole = dna_sequence_on_domain_after + dna_sequence_after
        else:
            dna_sequence_before_whole = None
            dna_sequence_after_whole = None

        self.strands.remove(strand)

        idt_present = strand.idt is not None
        strand_before = Strand(domains=domains_before + [domain_to_add_before],
                               dna_sequence=dna_sequence_before_whole,
                               color=strand.color, idt=strand.idt if idt_present else None)

        strand_after = Strand(domains=[domain_to_add_after] + domains_after,
                              dna_sequence=dna_sequence_after_whole,
                              use_default_idt=idt_present)

        self.helices[helix].domains.remove(domain_to_remove)
        self.helices[helix].domains.extend([domain_to_add_before, domain_to_add_after])

        self.strands.extend([strand_before, strand_after])

    def add_half_crossover(self, helix: int, helix2: int, offset: int, forward: bool,
                           offset2: int = None, forward2: bool = None):
        """
        Add a half crossover from helix `helix` at offset `offset` to `helix2`, on the strand
        with :py:data:`Strand.forward` = `forward`.

        Unlike :py:meth:`DNADesign.add_full_crossover`, which automatically adds a nick between the two
        half-crossovers, to call this method, there must *already* be nicks adjacent to the given
        offsets on the given helices. (either on the left or right side)

        :param helix: index of one helix of half crossover
        :param helix2: index of other helix of half crossover
        :param offset: offset on `helix` at which to add half crossover
        :param forward: direction of :any:`Strand` on `helix` to which to add half crossover
        :param offset2: offset on `helix2` at which to add half crossover.
            If not specified, defaults to `offset`
        :param forward2: direction of :any:`Strand` on `helix2` to which to add half crossover.
            If not specified, defaults to the negation of `forward`
        """
        if offset2 is None:
            offset2 = offset
        if forward2 is None:
            forward2 = not forward
        domain1 = self.domain_at(helix, offset, forward)
        domain2 = self.domain_at(helix2, offset2, forward2)
        if domain1 is None:
            raise IllegalDNADesignError(
                f"Cannot add half crossover at (helix={helix}, offset={offset}). "
                f"There is no Domain there.")
        if domain2 is None:
            raise IllegalDNADesignError(
                f"Cannot add half crossover at (helix={helix2}, offset={offset2}). "
                f"There is no Domain there.")
        strand1 = domain1.strand()
        strand2 = domain2.strand()

        if strand1 == strand2:
            raise IllegalDNADesignError(f"Cannot add crossover from "
                                        f"(helix={helix}, offset={offset}) to "
                                        f"(helix={helix2}, offset={offset2}) "
                                        f"because that would join two Domains "
                                        f"already on the same Strand! "
                                        f"Currently circular Strands are not supported. "
                                        f"Instead, try adding nicks first, or rearrange the order of "
                                        f"crossover addition, to ensure that all strands are "
                                        f"non-circular, even in intermediate stages.")

        if domain1.offset_3p() == offset and domain2.offset_5p() == offset2:
            strand_first = strand1
            strand_last = strand2
        elif domain1.offset_5p() == offset and domain2.offset_3p() == offset2:
            strand_first = strand2
            strand_last = strand1
        else:
            raise IllegalDNADesignError("Cannot add half crossover. Must have one domain have its "
                                        "5' end at the given offset and the other with its 3' end at the "
                                        "given offset, but this is not the case.")

        new_domains = strand_first.domains + strand_last.domains
        if strand_first.dna_sequence is None and strand_last.dna_sequence is None:
            new_dna = None
        elif strand_first.dna_sequence is not None and strand_last.dna_sequence is not None:
            new_dna = strand_first.dna_sequence + strand_last.dna_sequence
        else:
            raise IllegalDNADesignError(
                'cannot add crossover between two strands if one has a DNA sequence '
                'and the other does not')
        new_strand = Strand(domains=new_domains, color=strand_first.color, dna_sequence=new_dna,
                            idt=strand_first.idt)

        self.strands.remove(strand_first)
        self.strands.remove(strand_last)
        self.strands.append(new_strand)

    def add_full_crossover(self, helix: int, helix2: int, offset: int, forward: bool,
                           offset2: int = None, forward2: bool = None):
        """
        Adds two half-crossovers, one at `offset` and another at `offset`-1.
        Other arguments have the same meaning as in :py:meth:`DNADesign.add_half_crossover`.
        A nick is automatically added on helix `helix` between
        `offset` and `offset`-1 if one is not already present,
        and similarly for `offset2` on helix `helix2`.

        :param helix: index of one helix of half crossover
        :param helix2: index of other helix of half crossover
        :param offset: offset on `helix` at which to add half crossover
        :param forward: direction of :any:`Strand` on `helix` to which to add half crossover
        :param offset2: offset on `helix2` at which to add half crossover.
            If not specified, defaults to `offset`
        :param forward2: direction of :any:`Strand` on `helix2` to which to add half crossover.
            If not specified, defaults to the negation of `forward`
        """
        if offset2 is None:
            offset2 = offset
        if forward2 is None:
            forward2 = not forward
        for helix_, forward_, offset_ in [(helix, forward, offset), (helix2, forward2, offset2)]:
            self._prepare_nicks_for_full_crossover(helix_, forward_, offset_)
        self.add_half_crossover(helix=helix, helix2=helix2, offset=offset - 1, offset2=offset2 - 1,
                                forward=forward, forward2=forward2)
        self.add_half_crossover(helix=helix, helix2=helix2, offset=offset, offset2=offset2,
                                forward=forward, forward2=forward2)

    def add_crossovers(self, crossovers: List['Crossover']):  # remove quotes when Python 3.6 support dropped
        """
        Adds a list of :any:`Crossover`'s in batch.

        This helps to avoid problems where adding them one at a time using
        :py:meth:`DNADesign.add_half_crossover`
        or
        :py:meth:`DNADesign.add_full_crossover`
        creates an intermediate design with circular strands.

        :param crossovers: list of :any:`Crossover`'s to add. Its fields have the same meaning as in
            :py:meth:`DNADesign.add_half_crossover`
            and
            :py:meth:`DNADesign.add_full_crossover`,
            with the extra field `Crossover.half` indicating whether it represents a half or full crossover.
        """
        for crossover in crossovers:
            if not crossover.half:
                for helix, forward, offset in [(crossover.helix, crossover.forward, crossover.offset),
                                               (crossover.helix2, crossover.forward2, crossover.offset2)]:
                    self._prepare_nicks_for_full_crossover(helix, forward, offset)

        for crossover in crossovers:
            if crossover.half:
                self.add_half_crossover(helix=crossover.helix, helix2=crossover.helix2,
                                        forward=crossover.forward, forward2=crossover.forward2,
                                        offset=crossover.offset, offset2=crossover.offset2)
            else:
                self.add_full_crossover(helix=crossover.helix, helix2=crossover.helix2,
                                        forward=crossover.forward, forward2=crossover.forward2,
                                        offset=crossover.offset, offset2=crossover.offset2)

    def _prepare_nicks_for_full_crossover(self, helix, forward, offset):
        domain_right = self.domain_at(helix, offset, forward)
        if domain_right is None:
            raise IllegalDNADesignError(f'You tried to create a full crossover at '
                                        f'(helix={helix}, offset={offset}) '
                                        f'but there is no Strand there.')
        domain_left = self.domain_at(helix, offset - 1, forward)
        if domain_left is None:
            raise IllegalDNADesignError(f'You tried to create a full crossover at '
                                        f'(helix={helix}, offset={offset}) '
                                        f'but there is no Strand at offset {offset - 1}.')
        if domain_left == domain_right:
            self.add_nick(helix, offset, forward)
        else:
            assert domain_left.end == domain_right.start

    def inline_deletions_insertions(self):
        """
        Converts deletions and insertions by "inlining" them. Insertions and deletions are removed,
        and their domains have their lengths altered. Also, major tick marks on the helices will be
        shifted to preserve their adjacency to bases already present. For example, if there are major
        tick marks at 0, 8, 18, 24, and a deletion between 0 and 8:

        .. code-block:: none

            0      8         18    24    30
            |--X---|---------|-----|------

        then the domain is shortened by 1,
        the tick marks become 0, 7, 15, 23,
        and the helix's maximum offset is shrunk by 1:

        .. code-block:: none

            0     7         17    23    29
            |-----|---------|-----|------

        We assume that a major tick mark appears just to the LEFT of the offset it encodes,
        so the minimum and maximum offsets for tick marks are respectively the helix's minimum offset
        and 1 plus its maximum offset, the latter being just to the right of the last offset on the helix.
        """
        for helix in self.helices.values():
            self._inline_deletions_insertions_on_helix(helix)

    def _inline_deletions_insertions_on_helix(self, helix: Helix):
        ###################################################
        # first gather information before changing anything

        # gather all mods on helix
        deletions = [deletion for domain in helix.domains for deletion in domain.deletions]
        insertions = [insertion for domain in helix.domains for insertion in domain.insertions]

        # change max offset
        delta_length = sum(length for (offset, length) in insertions) - len(deletions)

        # combined collection of deletions/insertions into one dict mapping offset --> None/len, where
        # value of -1 indicates deletion, and otherwise is length of insertion
        dels_ins = dict()
        for deletion in deletions:
            dels_ins[deletion] = -1
        for insertion in insertions:
            dels_ins[insertion[0]] = insertion[1]

        # put offsets in sorted order
        dels_ins_offsets_sorted = sorted(dels_ins.keys())

        # fix helix major ticks
        major_ticks = sorted(helix.calculate_major_ticks(self.major_tick_distance))

        ###################################################
        # now that info is gathered, start changing things

        helix.max_offset += delta_length
        if len(major_ticks) > 0:
            major_tick_idx = 0
            delta_acc = 0  # accumulated delta; insertions add to this and deletions subtract from it
            for offset in dels_ins_offsets_sorted:
                # go to first major tick great than offset, updating passed ones by delta_acc
                while major_tick_idx < len(major_ticks) and major_ticks[major_tick_idx] <= offset:
                    major_ticks[major_tick_idx] += delta_acc
                    major_tick_idx += 1
                delta_acc += dels_ins[offset]
            # if necessary, update major ticks beyond last ins/del
            while major_tick_idx < len(major_ticks):
                major_ticks[major_tick_idx] += delta_acc
                major_tick_idx += 1
            # TODO: check if regularly spaced and reaching both ends, and if so set helix.major_tick_distance
            helix.major_ticks = major_ticks

        # fix domain start/end offsets
        domains = sorted(helix.domains, key=lambda domain: domain.start)
        delta_acc = 0
        for domain in domains:
            domain.start += delta_acc
            delta_acc += domain.dna_length() - domain.visual_length()
            domain.end += delta_acc
            domain.deletions = []
            domain.insertions = []

    def reverse_all(self):
        """
        Reverses "polarity" of every :any:`Strand` in this :any:`DNADesign`.

        No attempt is made to make any assigned DNA sequences match by reversing or rearranging them.
        Every :any:`Strand` keeps the same DNA sequence it had before (unreversed), if one was assigned.
        It is recommended to assign/reassign DNA sequences *after* doing this operation.
        """
        for strand in self.strands:
            strand.reverse()

    def set_major_tick_distance(self, major_tick_distance: int):
        self.major_tick_distance = major_tick_distance

    def _ensure_helices_distinct_objects(self):
        pair = _find_index_pair_same_object(self.helices)
        if pair:
            i, j = pair
            raise IllegalDNADesignError('helices must all be distinct objects, but those at indices '
                                        f'{i} and {j} are the same object')

    def _ensure_strands_distinct_objects(self):
        pair = _find_index_pair_same_object(self.strands)
        if pair:
            i, j = pair
            raise IllegalDNADesignError('strands must all be distinct objects, but those at indices '
                                        f'{i} and {j} are the same object')


def _find_index_pair_same_object(elts: Union[List, Dict]) -> Optional[Tuple]:
    # return pair of indices representing same object in elts, or None if they do not exist
    # input can be list or dict; if dict, returns pair of keys mapping to same object
    if isinstance(elts, list):
        elts = dict(enumerate(elts))
    for i, j in itertools.combinations(elts.keys(), 2):
        if elts[i] is elts[j]:
            return i, j
    return None


def _name_of_this_script() -> str:
    """Return name of the currently running script, WITHOUT the .py extension."""
    return os.path.basename(sys.argv[0])[:-3]


def _write_file_same_name_as_running_python_script(contents: str, extension: str, directory: str = '.',
                                                   filename=None):
    relative_filename = _get_filename_same_name_as_running_python_script(directory, extension, filename)
    with open(relative_filename, 'w') as out_file:
        out_file.write(contents)


def _get_filename_same_name_as_running_python_script(directory, extension, filename):
    if filename is None:
        filename = _name_of_this_script() + f'.{extension}'
    relative_filename = _create_directory_and_set_filename(directory, filename)
    return relative_filename


def _create_directory_and_set_filename(directory, filename):
    if not os.path.exists(directory):
        os.makedirs(directory)
    relative_filename = os.path.join(directory, filename)
    return relative_filename


@dataclass
class Crossover:
    """
    A :any:`Crossover` object represents the parameters to the methods
    :py:meth:`DNADesign.add_half_crossover`
    and
    :py:meth:`DNADesign.add_full_crossover`,
    with one more field :py:data:`Crossover.half` to identify whether it is a half or full crossover.

    It is used in conjection with :py:meth:`DNADesign.add_crossovers` to add many crossovers in batch.
    This helps avoid the issue that adding crossovers one at a time can lead to an intermediate
    :any:`DNADesign` with circular strands, which are currently unsupported.
    """

    helix: int
    """index of one helix of half crossover"""

    helix2: int
    """index of other helix of half crossover"""

    offset: int
    """offset on `helix` at which to add half crossover"""

    forward: bool
    """direction of :any:`Strand` on `helix` to which to add half crossover"""

    offset2: int = None
    """
    offset on `helix2` at which to add half crossover. 
    If not specified, defaults to `offset`
    """

    forward2: bool = None
    """
    direction of :any:`Strand` on `helix2` to which to add half crossover. 
    If not specified, defaults to the negation of `forward`
    """

    half: bool = False
    """
    Indicates whether this is a half or full crossover.
    If not specified, defaults to ``False``.
    """

    def __post_init__(self):
        if self.offset2 is None:
            self.offset2 = self.offset
        if self.forward2 is None:
            self.forward2 = not self.forward
