import re
from itertools import combinations
from unittest import TestCase

import numpy
import pytest

import cogent3
from cogent3.core import moltype as c3_moltype
from cogent3.core.location import (
    FeatureMap,
    IndelMap,
    LostSpan,
    Span,
    Strand,
    TerminalPadding,
    coords_intersect,
    coords_minus_coords,
    gap_coords_to_map,
    span_and_span,
)

DNA = cogent3.get_moltype("dna")


class SpanTests(TestCase):
    """Tests of the Span object."""

    def setUp(self):
        """Define some standard Spans"""
        self.empty = Span(0, 0)
        self.full = Span(35, 30)  # will convert to (30, 35) internally
        self.overlapping = Span(32, 36)
        self.inside = Span(31, 32)
        self.before = Span(25, 30)
        self.after = Span(35, 40)
        self.reverse = Span(30, 35, reverse=True)
        self.spans_zero = Span(-5, 5)

    def test_init(self):
        """Span object should init with start, end, and Length"""
        s = Span(0)
        assert s.start == 0
        assert s.end == 1
        assert s.reverse is False
        # to get an empty interval, must specify start and end explicitly
        t = Span(0, 0)
        assert t.start == 0
        assert t.end == 0
        assert t.reverse is False
        # should be able to specify direction also
        u = Span(5, 15, reverse=True)
        assert u.start == 5
        assert u.end == 15
        assert u.reverse is True
        # should be able to init from another span
        v = Span(u)
        assert v.start == 5
        assert v.end == 15
        assert v.reverse is True

    def test_contains(self):
        """Span object contains its start but not its end"""
        assert 0 not in self.empty
        assert 30 in self.full
        assert 34 in self.full
        assert 35 not in self.full
        assert self.inside in self.full
        assert self.overlapping not in self.full
        assert 0 in self.spans_zero
        assert -5 in self.spans_zero
        assert 5 not in self.spans_zero

    def test_overlaps(self):
        """Span objects should be able to overlap points or spans"""
        assert self.full.overlaps(self.overlapping)
        assert not self.full.overlaps(self.before)
        assert not self.before.overlaps(self.overlapping)
        assert not self.full.overlaps(self.after)
        assert not self.after.overlaps(self.before)
        assert self.full.overlaps(self.inside)
        assert self.spans_zero.overlaps(self.empty)
        assert self.empty.overlaps(self.spans_zero)

    def test_reverses(self):
        """Span.reverses should change direction"""
        assert not self.empty.reverse
        self.empty.reverses()
        assert self.empty.reverse
        self.empty.reverses()
        assert not self.empty.reverse
        assert self.reverse.reverse
        self.reverse.reverses()
        assert not self.reverse.reverse

    def test_iter(self):
        """Span iter should loop through (integer) contents"""
        assert list(iter(self.empty)) == []
        assert list(iter(self.full)) == [30, 31, 32, 33, 34]
        assert list(iter(self.spans_zero)) == [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4]
        assert list(iter(self.inside)) == [31]
        assert list(self.reverse) == [34, 33, 32, 31, 30]

    def test_str(self):
        """Span str should print start, stop, reverse"""
        assert str(self.empty) == "(0,0,False)"
        assert str(self.full) == "(30,35,False)"
        assert str(self.reverse) == "(30,35,True)"

    def test_len(self):
        """Span len should return difference between start and end"""
        assert len(self.empty) == 0
        assert len(self.full) == 5
        assert len(self.inside) == 1
        assert len(self.spans_zero) == 10

    def test_cmp(self):
        """Span cmp should support sort by 1st/2nd index and direction"""
        s, e, f, r, i, o = (
            self.spans_zero,
            self.empty,
            self.full,
            self.reverse,
            self.inside,
            self.overlapping,
        )

        n = Span(30, 36)

        expected_order = [s, e, f, r, n, i, o]
        first = expected_order[:]
        first.sort()
        second = [r, o, f, s, e, i, n]
        second.sort()
        for i, j in zip(first, second, strict=False):
            assert i is j

        for i, j in zip(first, expected_order, strict=False):
            assert i is j

    def test_sort(self):
        """Span should support sort by 1st/2nd index and direction"""
        s, e, _f, _r, i, _o = (
            self.spans_zero,
            self.empty,
            self.full,
            self.reverse,
            self.inside,
            self.overlapping,
        )

        expected_order = [s, e]
        first = expected_order[:]
        first.sort()

        for i, j in zip(first, expected_order, strict=False):
            assert i is j

    def test_starts_before(self):
        """Span starts_before should match hand-calculated results"""
        e, f = self.empty, self.full
        assert e.starts_before(f)
        assert not f.starts_before(e)
        assert e.starts_before(1)
        assert e.starts_before(1000)
        assert not e.starts_before(0)
        assert not e.starts_before(-1)
        assert not f.starts_before(30)
        assert f.starts_before(31)
        assert f.starts_before(1000)

    def test_starts_after(self):
        """Span starts_after should match hand-calculated results"""
        e, f = self.empty, self.full
        assert not e.starts_after(f)
        assert f.starts_after(e)
        assert not e.starts_after(1)
        assert not e.starts_after(1000)
        assert not e.starts_after(0)
        assert e.starts_after(-1)
        assert f.starts_after(29)
        assert not f.starts_after(30)
        assert not f.starts_after(31)
        assert not f.starts_after(1000)

    def test_startsAt(self):
        """Span startsAt should return True if input matches"""
        e, f = self.empty, self.full
        s = Span(30, 1000)
        assert e.starts_at(0)
        assert f.starts_at(30)
        assert s.starts_at(30)
        assert f.starts_at(s)
        assert s.starts_at(f)
        assert not e.starts_at(f)
        assert not e.starts_at(-1)
        assert not e.starts_at(1)
        assert not f.starts_at(29)

    def test_startsInside(self):
        """Span startsInside should return True if input starts inside span"""
        e, f, i, o = self.empty, self.full, self.inside, self.overlapping
        assert not e.starts_inside(0)
        assert not f.starts_inside(30)
        assert not e.starts_inside(f)
        assert i.starts_inside(f)
        assert not f.starts_inside(i)
        assert o.starts_inside(f)
        assert not o.ends_inside(i)

    def test_endsBefore(self):
        """Span endsBefore should match hand-calculated results"""
        e, f = self.empty, self.full
        assert e.ends_before(f)
        assert not f.ends_before(e)
        assert e.ends_before(1)
        assert e.ends_before(1000)
        assert not e.ends_before(0)
        assert not e.ends_before(-1)
        assert not f.ends_before(30)
        assert not f.ends_before(31)
        assert f.ends_before(1000)

    def test_endsAfter(self):
        """Span endsAfter should match hand-calculated results"""
        e, f = self.empty, self.full
        assert not e.ends_after(f)
        assert f.ends_after(e)
        assert not e.ends_after(1)
        assert not e.ends_after(1000)
        assert not e.ends_after(0)
        assert e.ends_after(-1)
        assert f.ends_after(29)
        assert f.ends_after(30)
        assert f.ends_after(34)
        assert not f.ends_after(35)
        assert not f.ends_after(1000)

    def test_endsAt(self):
        """Span endsAt should return True if input matches"""
        e, f = self.empty, self.full
        s = Span(30, 1000)
        t = Span(-100, 35)
        assert e.ends_at(0)
        assert f.ends_at(35)
        assert s.ends_at(1000)
        assert not f.ends_at(s)
        assert not s.ends_at(f)
        assert f.ends_at(t)
        assert t.ends_at(f)

    def test_ends_inside(self):
        """Span ends_inside should return True if input ends inside span"""
        e, f, i, o = self.empty, self.full, self.inside, self.overlapping
        assert not e.ends_inside(0)
        assert not f.ends_inside(30)
        assert not f.ends_inside(34)
        assert not f.ends_inside(35)
        assert not e.ends_inside(f)
        assert i.ends_inside(f)
        assert not f.ends_inside(i)
        assert not o.ends_inside(f)
        assert not o.ends_inside(i)
        assert e.ends_inside(Span(-1, 1))
        assert e.ends_inside(Span(0, 1))
        assert not e.ends_inside(Span(-1, 0))


class MapTests(TestCase):
    """tests of the Map class"""

    def test_get_gap_coords(self):
        """returns gap start and lengths"""
        m, _ = DNA.make_seq(seq="-AC--GT-TTA--").parse_out_gaps()
        got = m.get_gap_coordinates()
        assert dict(got) == {0: 1, 2: 2, 4: 1, 7: 2}


def test_span_lt_orders_by_indices():
    a = Span(start=0, end=10)
    b = Span(start=5, end=10)
    assert a < b
    assert not (b < a)


def test_span_lt_with_non_span_raises():
    s = Span(start=0, end=10)
    with pytest.raises(TypeError, match="Unsupported type"):
        _ = s < object()


def test_map_plus_position():
    # seq is 9 long
    # plus coords  012345678
    # +slice         **
    # plus seq     AAACCCTGG

    orig = FeatureMap.from_locations(locations=[(0, 9)], parent_length=9)
    assert orig.absolute_position(2) == 2
    assert orig.absolute_position(6) == 6

    assert orig.relative_position(2) == 2
    assert orig.relative_position(6) == 6

    subseq = orig[2:4]
    assert subseq.absolute_position(0) == 2
    assert subseq.absolute_position(4) == 6

    assert subseq.relative_position(2) == 0
    assert subseq.relative_position(6) == 4

    # minus coords 012345678
    # rel coord      01234
    # -slice         *****
    # minus seq    CCAGGGTTT
    # plus coords  876543210
    rc = orig.nucleic_reversed()
    rcsubseq = rc[2:7]
    rcsubseq.absolute_position(0)


def test_indel_map_useful_complete():
    im = IndelMap.from_spans(spans=[LostSpan(3)], parent_length=0)
    assert not im.useful
    assert not im.complete
    assert len(im) == 3


def test_map_nucleic_reversed():
    expect = [(0, 9)]
    # seq is 9 long
    # plus coords  012345678
    # plus seq     AAACCCTGG

    orig = FeatureMap.from_locations(locations=[(0, 9)], parent_length=9)
    # minus coords 012345678
    # rel coord      01234
    # -slice         *****
    # minus seq    CCAGGGTTT
    # plus coords  876543210
    rc = orig.nucleic_reversed()
    coords = rc.get_coordinates()
    assert coords == expect


@pytest.mark.parametrize("cls", [FeatureMap, IndelMap])
def test_coordinate(cls):
    # coordinates are for ungapped segments in underlying sequence
    #                       01   2 345
    seq = DNA.make_seq(seq="AC---G-TAA--")
    m, _ = seq.parse_out_gaps()
    m = cls.from_spans(spans=tuple(m.spans), parent_length=m.parent_length)
    got = m.get_coordinates()
    assert got == [(0, 2), (2, 3), (3, 6)]


@pytest.mark.parametrize("cls", [FeatureMap, IndelMap])
def test_gap_coordinate(cls):
    seq = DNA.make_seq(seq="AC---G-TAA--")
    m, _ = seq.parse_out_gaps()
    m = cls.from_spans(spans=tuple(m.spans), parent_length=m.parent_length)
    got = m.get_gap_coordinates()
    assert [tuple(c) for c in got] == [(2, 3), (3, 1), (6, 2)]


def test_gaps():
    # returns spans corresponding to position on "aligned" seq of gaps
    #                       000000000011
    #                       012345678901
    seq = DNA.make_seq(seq="AC---G-TAA--")
    m, _ = seq.parse_out_gaps()
    m = FeatureMap.from_spans(spans=tuple(m.spans), parent_length=m.parent_length)
    got = [(g.start, g.end) for g in tuple(m.gaps().spans)]
    assert got == [(2, 5), (6, 7), (10, 12)]


@pytest.mark.parametrize("cls", [IndelMap, FeatureMap])
def test_nongap(cls):
    # returns spans corresponding to position on "aligned" seq of nongaps
    #                       000000000011
    #                       012345678901
    seq = DNA.make_seq(seq="AC---G-TAA--")
    m, _ = seq.parse_out_gaps()
    m = cls.from_spans(spans=tuple(m.spans), parent_length=m.parent_length)

    got = [(s.start, s.end) for s in m.nongap()]
    assert got == [(0, 2), (5, 6), (7, 10)]


@pytest.mark.parametrize("cls", [IndelMap, FeatureMap])
def test_nongap_startswith(cls):
    # returns spans corresponding to position on "aligned" seq of nongaps
    #                   012345678
    seq = DNA.make_seq(seq="--G-TAA--")
    m, _ = seq.parse_out_gaps()
    m = cls.from_spans(spans=tuple(m.spans), parent_length=m.parent_length)

    got = [(s.start, s.end) for s in m.nongap()]
    assert got == [(2, 3), (4, 7)]


@pytest.mark.parametrize("cls", [IndelMap, FeatureMap])
def test_nongap_not_endswith(cls):
    # returns spans corresponding to position on "aligned" seq of nongaps
    #                   0123456
    seq = DNA.make_seq(seq="--G-TAA")
    m, _ = seq.parse_out_gaps()
    m = cls.from_spans(spans=tuple(m.spans), parent_length=m.parent_length)

    got = [(s.start, s.end) for s in m.nongap()]
    assert got == [(2, 3), (4, 7)]


def test_spans_gen():
    # returns spans corresponding to position on "aligned" seq of nongaps
    #                       000000000011
    #                       012345678901
    seq = DNA.make_seq(seq="AC---G-TAA--")
    expect = [Span(0, 2), LostSpan(3), Span(2, 3), LostSpan(1), Span(3, 6), LostSpan(2)]
    m, _ = seq.parse_out_gaps()
    gap_data = numpy.array([(2, 3), (3, 1), (6, 2)])
    pos, lengths = gap_data.T
    im = IndelMap(gap_pos=pos, gap_lengths=lengths, parent_length=m.parent_length)
    got = list(im.spans)
    assert got == expect


def test_spans_gap_start():
    # returns spans corresponding to position on "aligned" seq of nongaps
    #                       000000000011
    #                       012345678901
    seq = DNA.make_seq(seq="---TAA")
    expect = [LostSpan(3), Span(0, 3)]
    im, _ = seq.parse_out_gaps()
    got = list(im.spans)
    assert got == expect


def test_spans_gen_nogaps():
    # returns spans corresponding to position on "aligned" seq of nongaps
    #                       000000000011
    #                       012345678901
    seq = DNA.make_seq(seq="ACGTAA")
    m, _ = seq.parse_out_gaps()
    spans = list(m.spans)
    assert len(spans) == 1
    assert len(spans[0]) == len(seq)


def test_round_trip_rich_dict():
    seq = DNA.make_seq(seq="AC---G-TAA--")
    im, _ = seq.parse_out_gaps()
    rd = im.to_rich_dict()
    got = IndelMap.from_rich_dict(rd)
    assert im is not got
    assert got.to_rich_dict() == im.to_rich_dict()


def test_terminal_unknown():
    # seq  idx   01   2 345  6
    #           -AC---G-TAA--
    # aligned seq length is 13
    gap_data = numpy.array([[0, 1], [2, 3], [3, 1], [6, 2]])
    gap_pos, gap_lengths = gap_data.T

    m = IndelMap(
        gap_pos=gap_pos.copy(),
        gap_lengths=gap_lengths.copy(),
        parent_length=6,
    )
    # not unknown, by default
    m_spans = tuple(m.spans)
    assert m_spans[0].lost
    assert not isinstance(m_spans[0], TerminalPadding)
    # use the constructor arg
    m = IndelMap(
        gap_pos=gap_pos.copy(),
        gap_lengths=gap_lengths.copy(),
        parent_length=6,
        termini_unknown=True,
    )
    m_spans = tuple(m.spans)
    assert isinstance(m_spans[0], TerminalPadding)
    assert isinstance(m_spans[-1], TerminalPadding)
    assert m_spans[2].lost
    assert not isinstance(m_spans[1], TerminalPadding)
    assert m_spans[4].lost
    assert not isinstance(m_spans[2], TerminalPadding)

    # use the method
    m = IndelMap(
        gap_pos=gap_pos.copy(),
        gap_lengths=gap_lengths.copy(),
        parent_length=6,
    ).with_termini_unknown()
    m_spans = tuple(m.spans)
    assert isinstance(m_spans[0], TerminalPadding)
    assert isinstance(m_spans[-1], TerminalPadding)
    # middle gap is not terminal, so...
    assert not isinstance(m_spans[2], TerminalPadding)

    # no gaps, no effect
    # use the constructor arg
    empty = numpy.array([], dtype=int)
    m = IndelMap(
        gap_pos=empty.copy(),
        gap_lengths=empty.copy(),
        parent_length=6,
        termini_unknown=True,
    )
    m_spans = tuple(m.spans)
    assert len(m_spans) == 1
    assert not m_spans[0].lost
    # use the method
    m = IndelMap(
        gap_pos=empty.copy(),
        gap_lengths=empty.copy(),
        parent_length=6,
    ).with_termini_unknown()
    m_spans = tuple(m.spans)
    assert len(m_spans) == 1
    assert not m_spans[0].lost


def test_featuremap_inverse():
    m = FeatureMap.from_locations(locations=[(0, 2), (4, 6)], parent_length=6)
    assert len(m) == 4
    mi = m.inverse()
    assert len(mi) == 6
    mi_spans = tuple(mi.spans)
    assert mi_spans[1].lost
    assert len(mi_spans[1]) == 2
    # invert the inversion, should give us back the original
    re_inv = mi.inverse()
    expect = m.to_rich_dict()
    got = re_inv.to_rich_dict()
    assert got == expect


def test_indelmap_from_aligned_segments():
    locations = [(0, 2), (4, 6)]
    im = IndelMap.from_aligned_segments(locations=locations, aligned_length=6)
    assert len(im) == 6
    expected_length = sum(e - s for s, e in locations)
    assert im.parent_length == expected_length
    im_spans = tuple(im.spans)
    assert im_spans[1].lost
    assert len(im_spans[1]) == 2


@pytest.mark.parametrize("swap", [False, True])
def test_indelmap_inconsistent_input(swap):
    a = numpy.array([0, 1], dtype=int)
    b = numpy.array([3], dtype=int)
    a, b = (b, a) if swap else (a, b)
    with pytest.raises(ValueError):
        IndelMap(gap_pos=a, gap_lengths=b, parent_length=10)

    with pytest.raises(ValueError):
        IndelMap(gap_pos=a, cum_gap_lengths=b, parent_length=10)


def test_indelmap_from_aligned_segments2():
    locations = [(0, 5), (7, 12), (14, 21), (24, 27)]
    im = IndelMap.from_aligned_segments(locations=locations, aligned_length=27)
    expected_length = sum(e - s for s, e in locations)
    assert im.parent_length == expected_length


def test_indelmap_merge():
    im1 = IndelMap(
        gap_pos=numpy.array([], dtype=int),
        gap_lengths=numpy.array([], dtype=int),
        parent_length=4,
    )
    im2 = IndelMap(
        gap_pos=numpy.array([0], dtype=int),
        gap_lengths=numpy.array([2], dtype=int),
        parent_length=4,
    )

    assert len(im1) == 4
    assert im1.parent_length == 4
    inv = im1.merge_maps(im2)
    assert inv.parent_length == 4
    assert len(inv) == 6

    fmap1 = im1.to_feature_map()
    fmap2 = im2.to_feature_map()
    got = fmap2.inverse()[fmap1.inverse()].inverse()
    assert inv.get_gap_coordinates() == [
        list(coords) for coords in got.get_gap_coordinates()
    ]


def test_indelmap_merge_parent_length():
    im1 = IndelMap(
        gap_pos=numpy.array([], dtype=int),
        gap_lengths=numpy.array([], dtype=int),
        parent_length=4,
    )
    im2 = IndelMap(
        gap_pos=numpy.array([0], dtype=int),
        gap_lengths=numpy.array([2], dtype=int),
        parent_length=4,
    )
    # providing a value for parent_length overrides standard
    ov = im1.merge_maps(im2, parent_length=20)
    assert ov.parent_length != im2.parent_length
    assert ov.parent_length == 20


def test_map_indexed():
    m = FeatureMap.from_locations(locations=[(0, 2), (4, 6)], parent_length=6).inverse()
    indexed = m[2]
    assert len(indexed) == 1


def test_compare_map_indexed():
    from cogent3.core.alignment import Aligned

    raw_seq = "--AC-GTAA--"
    im, seq = DNA.make_seq(seq=raw_seq).parse_out_gaps()
    ia = Aligned.from_map_and_seq(im, seq)
    length = len(raw_seq)
    got = [str(ia[i]) for i in range(length)]
    expect = list("--AC-GTAA--")
    assert got == expect


@pytest.mark.parametrize("slice_it", [True, False])
def test_feature_map_zeroed(slice_it):
    spans = [LostSpan(2), Span(2, 4), LostSpan(2), Span(4, 8), LostSpan(2)]
    kwargs = {"spans": spans, "parent_length": 6}

    fm = FeatureMap(**kwargs)
    if slice_it:
        fm = fm[3:6]

    fm_zeroed = fm.zeroed()
    assert fm.start > 0
    assert fm_zeroed.start == 0


def test_indelmap_to_feature_map():
    spans = [LostSpan(2), Span(2, 4), LostSpan(2), Span(4, 8), LostSpan(2)]
    kwargs = {"spans": spans, "parent_length": 8}
    im = IndelMap.from_spans(**kwargs)
    mm = im.to_feature_map()
    assert mm.get_coordinates() == im.get_coordinates()


@pytest.mark.parametrize("raw", ["--AC--GGGG--", "A-A-A", "-A-AA----A"])
def test_indelmap_nucleic_reversed(raw):
    from cogent3.core.alignment import Aligned

    plus = DNA.make_seq(seq=raw)
    minus = plus.rc()
    plus_imap, _ = DNA.make_seq(seq=raw).parse_out_gaps()
    minus_imap, minus_seq = minus.parse_out_gaps()
    got = plus_imap.nucleic_reversed()
    assert got.get_coordinates() == minus_imap.get_coordinates()
    assert (got.gap_pos == minus_imap.gap_pos).all()
    assert (got.cum_gap_lengths == minus_imap.cum_gap_lengths).all()
    assert got.parent_length == minus_imap.parent_length
    assert str(Aligned.from_map_and_seq(got, minus_seq)) == str(minus)


def test_get_coords():
    """get_coordinates should return raw coordinates matching input"""
    spans = [(0, 9), (20, 32)]
    fmap = FeatureMap.from_locations(locations=spans, parent_length=100)
    coords = fmap.get_coordinates()
    assert coords == spans


def test_indel_map_get_coords():
    """get_coordinates should return raw coordinates matching input"""
    imap = IndelMap(
        gap_pos=numpy.array([9]),
        gap_lengths=numpy.array([10]),
        parent_length=20,
    )
    locations = [(0, 9), (9, 20)]
    coords = imap.get_coordinates()
    assert coords == locations


def test_indel_map_get_gap_coords():
    gap_data = numpy.array([(2, 3), (3, 1), (6, 2)])
    gap_pos, lengths = gap_data[:, 0], gap_data[:, 1]
    m = IndelMap(gap_pos=gap_pos, gap_lengths=lengths, parent_length=10)
    got = m.get_gap_coordinates()
    assert got == gap_data.tolist()


@pytest.mark.parametrize(
    "coords",
    [[(0, 3), (7, 11)], [(0, 3)], [(2, 4), (6, 10)], [(4, 6)]],
)
def test_indelmap_joined_segments(coords):
    raw = "--AC--GGGG--"
    expect, _ = DNA.make_seq(seq="".join(raw[s:e] for s, e in coords)).parse_out_gaps()
    imap, _ = DNA.make_seq(seq=raw).parse_out_gaps()
    got = imap.joined_segments(coords)
    assert got.gap_pos.tolist() == expect.gap_pos.tolist()
    assert got.cum_gap_lengths.tolist() == expect.cum_gap_lengths.tolist()


def test_indelmap_slice_terminating():
    raw = "-CB-A--"
    start, end = 4, 6
    expect, _ = DNA.make_seq(seq=raw[start:end]).parse_out_gaps()
    imap, _ = DNA.make_seq(seq=raw).parse_out_gaps()
    got = imap[start:end]
    assert got.gap_pos.tolist() == expect.gap_pos.tolist()
    assert got.cum_gap_lengths.tolist() == expect.cum_gap_lengths.tolist()


def test_indelmap_slice_zero():
    raw = "-CB-A--"
    start, end = 0, 0
    expect, s = DNA.make_seq(seq=raw[start:end]).parse_out_gaps()
    imap, _ = DNA.make_seq(seq=raw).parse_out_gaps()
    got = imap[start:end]
    assert got.parent_length == len(s)
    assert got.gap_pos.tolist() == expect.gap_pos.tolist()
    assert got.cum_gap_lengths.tolist() == expect.cum_gap_lengths.tolist()


def test_indelmap_invalid_slice_range():
    # If we mirror python slicing, an invalid slice should return an empty map
    imap = IndelMap(
        gap_pos=numpy.array([10], dtype=int),
        gap_lengths=numpy.array([2], dtype=int),
        parent_length=10,
    )

    expect = numpy.array([], dtype=int)

    got = imap[-100]
    assert (got.gap_pos == expect).all()
    assert (got.cum_gap_lengths == expect).all()

    got = imap[-100:]
    assert (got.gap_pos == expect).all()
    assert (got.cum_gap_lengths == expect).all()

    got = imap[:-99]
    assert (got.gap_pos == expect).all()
    assert (got.cum_gap_lengths == expect).all()


def test_indelmap_get_indices_errors():
    imap = IndelMap(
        gap_pos=numpy.array([10], dtype=int),
        gap_lengths=numpy.array([2], dtype=int),
        parent_length=10,
    )
    with pytest.raises(IndexError):
        imap.get_align_index(-1000)


def test_indelmap_slice_innner_gap():
    start, end = 4, 6
    raw = "--AC--GGGG--"
    expect, _ = DNA.make_seq(seq=raw[start:end]).parse_out_gaps()
    imap, _ = DNA.make_seq(seq=raw).parse_out_gaps()
    imap = imap[start:end]
    assert imap.gap_pos.tolist() == expect.gap_pos.tolist()


def test_indelmap_slice_cum_length():
    start, end = 7, 11
    raw = "--AC--GGGG--"
    expect, _ = DNA.make_seq(seq=raw[start:end]).parse_out_gaps()
    imap, _ = DNA.make_seq(seq=raw).parse_out_gaps()
    imap = imap[start:end]
    assert imap.gap_pos.tolist() == expect.gap_pos.tolist()
    assert imap.cum_gap_lengths.tolist() == expect.cum_gap_lengths.tolist()


def test_get_coords_invalid_order():
    """get_coordinates should return raw coordinates matching input"""

    # should work for reversed Maps too
    spans = [(32, 20), (9, 0)]
    with pytest.raises(ValueError):
        FeatureMap.from_locations(locations=spans, parent_length=100)


def test_indelmap_slice_gap_into_seq():
    pos, lengths = numpy.array([[3, 1], [7, 1]]).T
    gaps = IndelMap(gap_pos=pos, gap_lengths=lengths, parent_length=7)
    # example seq
    # 012345678
    # 012 3456
    # ACA-CGAC-
    # slicing from aligned coords 3-5, gives '-C'
    got = gaps[3:5]
    assert got.gap_pos.tolist() == [0]
    assert got.cum_gap_lengths.tolist() == [1]


def test_indelmap_slice_one_gap():
    pos, lengths = numpy.array([[3, 6]]).T
    gaps = IndelMap(gap_pos=pos, gap_lengths=lengths, parent_length=24)
    # example seq
    #           1
    # 01234567890
    # 012      3456789
    # GTG------GTAGAAGTTCCAAATAATGAA
    # slicing from aligned coords 3-5, gives 'TG------G'
    # in sliced seq, gap at 2
    got = gaps[1:10]
    assert got.gap_pos.tolist() == [2]
    assert got.cum_gap_lengths.tolist() == [6]


@pytest.mark.parametrize(
    "data",
    [
        "AB---CD--EF",
        "---ABCD--EF",
        "ABCD---EF--",
        "-----ABCDEF",
        "ABCDEF-----",
        "-ABCDEF----",
        "-A-B-C-D-EF",
        "A-B-C-D-EF-",
    ],
)
@pytest.mark.parametrize("index", range(4, 6))  # the ungapped sequence is 6 long
def test_gapped_convert_seq2aln(data, index):
    # converting a sequence index to alignment index
    ungapped = data.replace("-", "")
    seq = cogent3.make_seq(data, moltype="text")
    gaps, _ = seq.parse_out_gaps()
    idx = gaps.get_align_index(index)
    assert data[idx] == ungapped[index]


@pytest.mark.parametrize(
    "data",
    [
        "AB---CD--EF",
        "---ABCD--EF",
        "ABCD---EF--",
        "-----ABCDEF",
        "ABCDEF-----",
        "-ABCDEF----",
        "-A-B-C-D-EF",
        "A-B-C-D-EF-",
    ],
)
@pytest.mark.parametrize(
    ("start", "end"),
    list(combinations(range(6), 2)),
)  # the ungapped sequence is 6 long
def test_indelmap_align_index_slice_stop(data, start, end):
    # converting a sequence index to alignment index
    ungapped = data.replace("-", "")
    seq = cogent3.make_seq(data, moltype="text")
    gaps, _ = seq.parse_out_gaps()
    stop = gaps.get_align_index(end, slice_stop=True)
    assert data[stop - 1] == ungapped[end - 1]


@pytest.mark.parametrize(
    "data",
    [
        "AB---CD--EF",
        "---ABCD--EF",
        "ABCD---EF--",
        "-----ABCDEF",
        "ABCDEF-----",
        "-ABCDEF----",
        "-A-B-C-D-EF",
        "A-B-C-D-EF-",
    ],
)
@pytest.mark.parametrize("index", range(6))  # the ungapped sequence is 6 long
def test_gapped_convert_seq2aln2seq(data, index):
    # round tripping seq to alignment to seq indices
    gaps, _ = cogent3.make_seq(data, moltype="text").parse_out_gaps()
    align_index = gaps.get_align_index(index)
    got = gaps.get_seq_index(align_index)
    assert got == index


@pytest.mark.parametrize("expect", range(10))
def test_indelmap_get_seq_index_negative(expect):
    parent_length = 10
    gap_pos = [0]
    gap_lengths = [3]
    imap = IndelMap(
        gap_pos=numpy.array(gap_pos),
        gap_lengths=numpy.array(gap_lengths),
        parent_length=parent_length,
    )
    neg_index = expect - parent_length
    got = imap.get_seq_index(neg_index)
    assert got == expect


@pytest.mark.parametrize("expect", range(10))
def test_indelmap_get_align_index_negative(expect):
    parent_length = 10
    gap_pos = [0]
    gap_lengths = [3]
    gap_length = gap_lengths[0]
    imap = IndelMap(
        gap_pos=numpy.array(gap_pos),
        gap_lengths=numpy.array(gap_lengths),
        parent_length=parent_length,
    )
    neg_index = expect + gap_length - len(imap)
    got = imap.get_seq_index(neg_index)
    assert got == expect


@pytest.mark.parametrize(
    "data",
    [
        "AB--CDE-FG",
        "--ABC-DEFG",
        "AB--CDE-FG--",
        "ABCDE--FG---",
        "-----ABCDEFG",
        "-A-B-C-D-E-F-G-",
    ],
)
@pytest.mark.parametrize("seq_index", range(7))
def test_gapped_convert_aln2seq_nongap_char(data, seq_index):
    # test alignment indexes when aligned position is NOT a gap
    ungapped = "ABCDEFG"
    align_index = data.find(ungapped[seq_index])
    gaps, _ = cogent3.make_seq(data, moltype="text").parse_out_gaps()
    idx = gaps.get_seq_index(align_index)
    assert idx == seq_index


def _find_nth_gap_index(data: str, n: int) -> int:
    num = -1
    for i, c in enumerate(data):
        if c == "-":
            num += 1
        if num == n:
            return i
    msg = f"{data=}, {n=}"
    raise ValueError(msg)


def _get_expected_seqindex(data: str, align_index: int) -> int:
    # compute the expected seqindex
    refseq = data.replace("-", "")
    got = data[align_index:].lstrip("-")
    return refseq.find(got[0]) if got else len(refseq)


@pytest.mark.parametrize(
    "data",
    [
        "AB-----CDE-F--G",
        "----ABC-DEFG---",
        "AB--CDE-FG-----",
        "ABCDE--FG------",
        "--------ABCDEFG",
        "-A-B-C-D-E-F-G-",
    ],
)
@pytest.mark.parametrize("gap_number", range(8))
def test_gapped_convert_aln2seq_gapchar(data, gap_number):
    # test alignment indexes when aligned position IS a gap
    # in this case we expect the position of the next non-gap
    # to be the result
    # find the alignment index corresponding to the
    align_index = _find_nth_gap_index(data, gap_number)
    assert data[align_index] == "-", (data, gap_number)
    # find nearest non-gap
    seq_index = _get_expected_seqindex(data, align_index)
    gaps, _ = cogent3.make_seq(data, moltype="text").parse_out_gaps()
    idx = gaps.get_seq_index(align_index)
    assert idx == seq_index


def test_gapped_convert_aln2seq_invalid():
    gaps, _ = cogent3.make_seq(seq="AC--GTA-TG", moltype="dna").parse_out_gaps()
    with pytest.raises(IndexError):
        # absolute value of negative indices must be < seq length
        gaps.get_seq_index(-100)


@pytest.mark.parametrize(
    "aslice",
    [
        slice(3, 7),
        slice(20, None),
    ],
)
def test_no_gaps_in_slice(aslice):
    # aligned length is 25
    seq_length = 20
    gap_length = 5
    pos, lengths = numpy.array([[10, gap_length]], dtype=numpy.int32).T
    gp = IndelMap(gap_pos=pos, gap_lengths=lengths, parent_length=seq_length)
    got = gp[aslice]
    assert not got.num_gaps
    start = aslice.start or 0
    stop = aslice.stop or (seq_length + gap_length)
    assert len(got) == stop - start


def test_len_gapped():
    seq_length = 20
    gap_length = 5
    pos, lengths = numpy.array([[10, gap_length]], dtype=numpy.int32).T
    gp = IndelMap(gap_pos=pos, gap_lengths=lengths, parent_length=seq_length)
    assert len(gp) == (seq_length + gap_length)


def test_all_gaps_in_slice():
    # slicing GapPositions
    # sample seq 1
    data = "AC--GTA-TG"
    gp, _ = cogent3.make_seq(data, moltype="dna").parse_out_gaps()
    sl = slice(1, 9)

    got = gp[sl]
    expect_gaps, _ = cogent3.make_seq(data[sl], moltype="dna").parse_out_gaps()
    assert got.get_gap_coordinates() == expect_gaps.get_gap_coordinates()
    assert got.parent_length == 5


@pytest.mark.parametrize(
    "data",
    [
        "----GTA-TG",
        "AC--GTA---",
        "AC--GTA-TG",
        "A-C-G-T-A-",
        "-A-C-G-T-A",
        "ACGTAACGTA",
        "----------",
    ],
)
@pytest.mark.parametrize(
    "aslice",
    [slice(i, j) for i, j in combinations(range(10), 2)],
)
def test_variant_slices(data, aslice):
    gaps, _ = cogent3.make_seq(data, moltype="dna").parse_out_gaps()
    got = gaps[aslice]

    expect_gaps, expect_seq = cogent3.make_seq(
        data[aslice],
        moltype="dna",
    ).parse_out_gaps()
    assert got.parent_length == len(expect_seq)
    assert (got.gap_pos == expect_gaps.gap_pos).all()
    assert (got.cum_gap_lengths == expect_gaps.cum_gap_lengths).all()


def test_indelmap_joined():
    pos = numpy.array([0, 1])
    cum_len = numpy.array([1, 5])
    imap = IndelMap(gap_pos=pos, cum_gap_lengths=cum_len, parent_length=5)
    fmap = FeatureMap.from_locations(locations=[(0, 1), (2, 5)], parent_length=10)
    coords = fmap.get_coordinates()
    got = imap.joined_segments(coords)
    assert got.num_gaps == 1
    assert got.gap_pos[0] == 0
    assert got.cum_gap_lengths[0] == (1 + 5 - 2)


def test_indel_map_sliced_with_negative():
    imap = IndelMap(
        gap_pos=numpy.array([0]),
        cum_gap_lengths=numpy.array([1]),
        parent_length=14,
    )
    got = imap[:-3]
    assert got.parent_length == 14 - 3


def test_indelmap_roundtrip_json():
    from scinexus.deserialise import deserialise_object

    imap = IndelMap(
        gap_pos=numpy.array([0, 9]),
        cum_gap_lengths=numpy.array([1, 3]),
        parent_length=14,
    )
    got = deserialise_object(imap.to_json())
    assert (got.gap_pos == imap.gap_pos).all()
    assert (got.cum_gap_lengths == imap.cum_gap_lengths).all()
    assert got.parent_length == imap.parent_length


def test_featuremap_roundtrip_json():
    from scinexus.deserialise import deserialise_object

    fmap = FeatureMap.from_locations(
        locations=[[0, 9], [20, 23]],
        parent_length=140,
    )
    got = deserialise_object(fmap.to_json())
    coords = fmap.get_coordinates()
    assert coords == [(0, 9), (20, 23)]
    assert got.parent_length == fmap.parent_length == 140


@pytest.mark.parametrize(
    ("error_type", "locations"),
    [(ValueError, ((-2, 2),)), (RuntimeError, ((20, 25),))],
)
def test_invalid_locations(error_type, locations):
    with pytest.raises(error_type):
        FeatureMap.from_locations(locations=locations, parent_length=10)


def test_gap_coords_to_map():
    """construct a Map from coordinates of gap alone"""
    gap_coords = {0: 1, 2: 2, 4: 1, 7: 2}
    seqlen = 70
    got = gap_coords_to_map(gap_coords, seqlen)
    assert len(got) == seqlen + sum(gap_coords.values())

    gap_coords = {5: 2, 17: 3, 10: 2}
    seqlen = 20
    got = gap_coords_to_map(gap_coords, seqlen)
    assert len(got) == sum(gap_coords.values()) + seqlen

    # roundtrip from Map.get_gap_coordinates()
    assert dict(got.get_gap_coordinates()) == gap_coords

    # and no gaps
    m, seq = DNA.make_seq(seq="ACGTTTA").parse_out_gaps()
    got = gap_coords_to_map({}, len(seq))
    assert len(got) == len(m)
    assert got.get_coordinates() == m.get_coordinates()

    # and gaps outside sequence
    with pytest.raises(ValueError):
        gap_coords_to_map({20: 1}, len(seq))


@pytest.mark.parametrize(
    ("seq", "expected"),
    [
        ("ACGTACGT", []),
        ("----ACGTACGT----", [(0, 4), (12, 16)]),
        ("ACGT----ACGT", [4, 8]),
        ("----", [[0, 4]]),
        ("----ACGT", [[0, 4]]),
        ("ACGTACGT----", [(8, 12)]),
    ],
)
def test_get_gap_align_coordinates1(seq, expected):
    expected = numpy.array(expected, dtype=int)
    if not len(expected):
        expected = expected.reshape((0, 2))
    im, _ = cogent3.make_seq(seq, moltype="dna").parse_out_gaps()
    result = im.get_gap_align_coordinates()
    assert (result == expected).all(), f"{expected=}, {result=}"


@pytest.mark.parametrize(
    ("seq", "expected"),
    [
        ("", []),
        ("A", []),
        ("-", [[0, 1]]),
    ],
)
def test_get_gap_align_coordinates_edge_cases(seq, expected):
    expected = numpy.array(expected, dtype=int)
    if not len(expected):
        expected = expected.reshape((0, 2))

    im, _ = cogent3.make_seq(seq, moltype="text").parse_out_gaps()
    result = im.get_gap_align_coordinates()
    assert (result == expected).all(), f"{expected=}, {result=}"


def test_featuremap_add():
    spans_a = [LostSpan(2), Span(2, 4)]
    spans_b = [LostSpan(2), Span(4, 8), LostSpan(2)]
    kwargs = {"parent_length": 6}

    fm_a = FeatureMap(spans=spans_a, **kwargs)
    fm_b = FeatureMap(spans=spans_b, **kwargs)
    fm_ab = fm_a + fm_b
    assert list(fm_ab.spans) == (spans_a + spans_b)


def test_featuremap_mul():
    spans = [LostSpan(2), Span(2, 4)]
    fm = FeatureMap(spans=spans, parent_length=6)
    fm_3 = fm * 3
    assert list(fm_3.spans) == [sp * 3 for sp in spans]
    assert fm_3.parent_length == 6 * 3


def test_featuremap_div():
    spans = [LostSpan(3), Span(3, 6)]
    fm_3 = FeatureMap(spans=spans, parent_length=6)
    fm_1 = fm_3 / 3
    assert list(fm_1.spans) == [sp / 3 for sp in spans]
    assert fm_1.parent_length == 6 / 3


# Constants
EMPTY = None, None


@pytest.mark.parametrize(
    ("span1", "span2", "expected"),
    [
        ((1, 5), (2, 4), (2, 4)),  # span1 contains span2
        ((2, 4), (1, 5), (2, 4)),  # span1 within span2
        ((1, 5), (1, 3), (1, 3)),  # same start, different end
        ((1, 3), (1, 5), (1, 3)),  # same start, different end
        ((1, 5), (2, 5), (2, 5)),  # different start, same end
        ((2, 5), (1, 5), (2, 5)),  # different start, same end
        ((1, 4), (2, 5), (2, 4)),  # span1 overlaps start of span2
        ((1, 9), (6, 10), (6, 9)),  # span1 overlaps start of span2
        ((2, 5), (1, 4), (2, 4)),  # span1 overlaps end of span2
    ],
)
def test_span_and_span(span1, span2, expected):
    result = span_and_span(span1, span2)
    assert result == expected


@pytest.mark.parametrize(
    ("span1", "span2"),
    [
        ((5, 1), (2, 4)),
        ((1, 5), (4, 2)),
    ],
)
def test_span_and_span_error_cases(span1, span2):
    with pytest.raises(ValueError):
        span_and_span(span1, span2)


@pytest.mark.parametrize(
    ("arr1", "arr2", "expected"),
    [
        # unique or equal
        ([[1, 3], [5, 7]], [[2, 4], [6, 8]], [[1, 2], [5, 6]]),
        ([[1, 2], [3, 4], [5, 6]], [[5, 6], [7, 8]], [[1, 2], [3, 4]]),
        # unique or overlap
        ([[1, 4], [7, 9]], [[3, 4]], [[1, 3], [7, 9]]),
        ([[1, 2], [5, 9]], [[2, 6], [8, 10]], [[1, 2], [5, 7]]),
    ],
)
def test_coords_sub_coords(arr1, arr2, expected):
    arr1 = numpy.array(arr1)
    arr2 = numpy.array(arr2)
    result = coords_minus_coords(arr1, arr2)
    assert numpy.array_equal(result, expected)


@pytest.mark.parametrize(
    ("arr1", "arr2", "expected"),
    [
        ([(1, 5)], [(2, 3)], [(2, 3)]),
        ([(1, 5)], [(0, 2), (3, 4)], [(1, 2), (3, 4)]),
        ([(1, 5), (6, 10)], [(2, 3), (7, 8)], [(2, 3), (7, 8)]),
        ([(1, 5), (6, 10), (12, 14)], [(2, 3), (7, 8)], [(2, 3), (7, 8)]),
        ([(1, 9), (12, 14)], [(2, 4), (6, 10)], [(2, 4), (6, 9)]),
        ([(1, 5)], [(5, 6)], []),
        ([(1, 5)], [(0, 1)], []),
        ([(1, 5)], [(4, 5)], [(4, 5)]),
        ([(1, 5)], [(1, 5)], [(1, 5)]),
        ([(1, 5)], [(0, 6)], [(1, 5)]),
        ([], [(1, 2)], []),
        ([(1, 2)], [], []),
        ([], [], []),
    ],
)
def test_coord_intersect(arr1, arr2, expected):
    result = coords_intersect(arr1, arr2)
    assert result == expected


RAW_SEQS = (
    "----GTA-TG",
    "AC--GTA---",
    "AC--GTA-TG",
    "A-C-G-T-A-",
    "-A-C-G-T-A",
    "ACGTAACGTA",
    "AC--AACGTA",
    "ACGTAA--TA",
    "GTATG-----",
    "-----GTATG",
    "----------",
)

gap_run = re.compile("-+")


@pytest.fixture(scope="session", params=combinations(RAW_SEQS, 2))
def seq_pairs(request):
    raw1, raw2 = request.param
    return cogent3.make_seq(raw1, moltype="dna"), cogent3.make_seq(raw2, moltype="dna")


@pytest.mark.parametrize("as_array", [True, False])
def test_indelmap_shared_gaps(seq_pairs, as_array):
    # we check against a sequence modified such that only the shared
    # gaps are retained
    s1, s2 = seq_pairs
    common = "".join(
        "-" if f"{a}{b}" == "--" else "X"
        for a, b in zip(str(s1), str(s2), strict=False)
    )
    expect = [(m.start(), m.end()) for m in gap_run.finditer(common)]
    ig1, s1 = s1.parse_out_gaps()
    ig2, s2 = s2.parse_out_gaps()
    arg = ig2.get_gap_align_coordinates() if as_array else ig2
    got = ig1.shared_gaps(arg)
    numpy.testing.assert_allclose(got, expect)


@pytest.mark.parametrize("as_array", [True, False])
def test_indelmap_subtraction(seq_pairs, as_array):
    s1, s2 = seq_pairs
    unique_gaps = "".join(
        a for a, b in zip(str(s1), str(s2), strict=False) if f"{a}{b}" != "--"
    )
    ig1, s1 = s1.parse_out_gaps()
    ig2, s2 = s2.parse_out_gaps()
    arg = ig2.get_gap_align_coordinates() if as_array else ig2
    got1 = ig1.minus_gaps(arg)
    expect, _ = cogent3.make_seq(unique_gaps, moltype="dna").parse_out_gaps()
    numpy.testing.assert_allclose(
        got1.get_gap_coordinates(),
        expect.get_gap_coordinates(),
    )


def test_indelmap_subtraction_build_aligned(seq_pairs):
    from cogent3.core.alignment import Aligned

    s1, s2 = seq_pairs

    unique_gaps = "".join(
        a for a, b in zip(str(s1), str(s2), strict=False) if f"{a}{b}" != "--"
    )
    ig1, s1 = s1.parse_out_gaps()
    ig2, s2 = s2.parse_out_gaps()
    got1 = ig1.minus_gaps(ig2.get_gap_align_coordinates())
    got = Aligned.from_map_and_seq(got1, s1)
    assert str(got) == unique_gaps


def test_indelmap_combined_and_subtract(seq_pairs):
    s1, s2 = seq_pairs
    ig1, s1 = s1.parse_out_gaps()
    ig2, s2 = s2.parse_out_gaps()
    expect = ig1.minus_gaps(ig2.get_gap_align_coordinates())
    intersect = ig1.shared_gaps(ig2)
    got = ig1.minus_gaps(intersect)
    numpy.testing.assert_allclose(
        got.get_gap_coordinates(),
        expect.get_gap_coordinates(),
    )


def test_indelmap_shared_gap_error():
    ig1 = IndelMap(
        gap_pos=numpy.array([1, 3]),
        gap_lengths=numpy.array([2, 1]),
        parent_length=5,
    )
    ig2 = IndelMap(
        gap_pos=numpy.array([1, 3]),
        gap_lengths=numpy.array([2, 1]),
        parent_length=10,
    )
    with pytest.raises(AssertionError):
        ig1.shared_gaps(ig2)


def test_indelmap_minus_gap_error():
    ig1 = IndelMap(
        gap_pos=numpy.array([1, 3]),
        gap_lengths=numpy.array([2, 1]),
        parent_length=5,
    )
    ig2 = IndelMap(
        gap_pos=numpy.array([1, 3]),
        gap_lengths=numpy.array([2, 100]),
        parent_length=10,
    )
    with pytest.raises(AssertionError):
        ig1.minus_gaps(ig2.get_gap_align_coordinates())


def test_indelmap_invalid_slice_type():
    imap = IndelMap(
        gap_pos=numpy.array([10], dtype=int),
        gap_lengths=numpy.array([2], dtype=int),
        parent_length=10,
    )
    with pytest.raises(NotImplementedError):
        imap[()]  # pylint: disable=pointless-statement


def test_indelmap_make_seq_feature_map():
    #           1
    # 01234567890
    # AC--GTA-TAA
    # 01    234 567
    im = IndelMap(
        gap_pos=numpy.array([2, 5], dtype=int),
        gap_lengths=numpy.array([2, 1], dtype=int),
        parent_length=8,
    )
    orig_spans = [Span(1, 5)]
    align_map = FeatureMap(spans=orig_spans, parent_length=11)
    spans = [Span(1, 3)]
    expect = FeatureMap(spans=spans, parent_length=8)
    got = im.make_seq_feature_map(align_map)
    assert got.get_coordinates() == expect.get_coordinates()
    assert got.parent_length == expect.parent_length

    # ignoring lost spans
    align_map = FeatureMap(spans=[*orig_spans, LostSpan(4)], parent_length=11)
    got = im.make_seq_feature_map(align_map)
    assert got.get_coordinates() == expect.get_coordinates()
    assert got.parent_length == expect.parent_length


@pytest.mark.parametrize("start", range(10))
@pytest.mark.parametrize("stop", range(11))
@pytest.mark.parametrize("step", range(1, 5))
@pytest.mark.parametrize(
    "data",
    [
        "TCAGTCAGTC",
        "AAAAA-----",
        "-----AAAAA",
        "--AA--AA--",
        "AA--AA--AA",
        "A-A-A-A-A-",
        "---TTTT---",
        "C--------C",
        "----------",
    ],
)
def test_indelmap_positive_step_variant_slices(start, stop, step, data):
    imap, _ = c3_moltype.DNA.make_seq(seq=data).parse_out_gaps()
    got = imap[start:stop:step]
    expect, _ = c3_moltype.DNA.make_seq(seq=data[start:stop:step]).parse_out_gaps()
    assert (got.gap_pos == expect.gap_pos).all()
    assert (got.cum_gap_lengths == expect.cum_gap_lengths).all()


@pytest.mark.parametrize("start", range(11))
@pytest.mark.parametrize("stop", range(10))
@pytest.mark.parametrize("step", [-1, -2, -3])
@pytest.mark.parametrize(
    "data",
    [
        "TCAGTCAGTC",
        "AAAAA-----",
        "AAAAAAA--A",
        "-----AAAAA",
        "--AA--AA--",
        "AA--AA--AA",
        "A-A-A-A-A-",
        "---TTTT---",
        "C--------C",
        "----------",
    ],
)
def test_indelmap_negative_step_variant_slices(start, stop, step, data):
    imap, _ = c3_moltype.DNA.make_seq(seq=data).parse_out_gaps()
    got = imap[start:stop:step]
    expect, _ = c3_moltype.DNA.make_seq(seq=data[start:stop:step]).parse_out_gaps()
    assert (got.gap_pos == expect.gap_pos).all()
    assert (got.cum_gap_lengths == expect.cum_gap_lengths).all()


@pytest.mark.parametrize("scale", [1 / 2, 1 / 1.1])
def test_indelmap_scaled_invalid(scale):
    im = IndelMap(
        gap_pos=numpy.array([1, 3]),
        gap_lengths=numpy.array([2, 1]),
        parent_length=5,
    )
    with pytest.raises(ValueError):
        im.scaled(scale)


@pytest.mark.parametrize("scale", [1, 1.0])
def test_indelmap_scaled_one(scale):
    im = IndelMap(
        gap_pos=numpy.array([1, 3]),
        gap_lengths=numpy.array([2, 1]),
        parent_length=5,
    )
    got = im.scaled(scale)
    assert got is im


@pytest.fixture
def codon_and_aa_maps():
    import cogent3

    data = {"s1": "ATG --- --- GAT --- AAA", "s2": "ATG CAA TCG AAT GAA ATA"}
    dna = cogent3.make_aligned_seqs(
        {n: s.replace(" ", "") for n, s in data.items()},
        moltype="dna",
    )
    aa = dna.get_translation()
    dna_1 = dna.seqs["s1"].map
    aa_1 = aa.seqs["s1"].map
    return dna_1, aa_1


def test_indelmap_scaled(codon_and_aa_maps):
    cmap, amap = codon_and_aa_maps
    cmap_to_amap = cmap.scaled(1 / 3)
    amap_to_cmap = amap.scaled(3)
    numpy.testing.assert_allclose(cmap_to_amap.array, amap.array)
    numpy.testing.assert_allclose(amap_to_cmap.array, cmap.array)


def test_indelmap_merge_ending_gap():
    im1 = IndelMap(
        gap_pos=numpy.array([5], dtype=int),
        gap_lengths=numpy.array([6], dtype=int),
        parent_length=5,
    )
    im2 = IndelMap(
        gap_pos=numpy.array([11], dtype=int),
        gap_lengths=numpy.array([2], dtype=int),
        parent_length=11,
    )
    # providing a value for parent_length overrides standard
    ov = im1.merge_maps(im2, parent_length=im2.parent_length, aligned_indices=True)
    assert ov.gap_pos.tolist() == [5]
    assert ov.cum_gap_lengths.tolist() == [8]


def test_indel_map_merge_aligned_indices():
    im1 = IndelMap(
        gap_pos=numpy.array([0, 4, 7], dtype=int),
        gap_lengths=numpy.array([14, 9, 2], dtype=int),
        parent_length=14,
    )
    # in im2, the first and second gaps fall within the first
    # gap of im1 producing redundant seq indices that have to be
    # merged and their individual lengths summed
    im2 = IndelMap(
        gap_pos=numpy.array([3, 6, 31], dtype=int),
        gap_lengths=numpy.array([1, 2, 1], dtype=int),
        parent_length=46,
    )
    merged = im1.merge_maps(im2, parent_length=46, aligned_indices=True)
    exp = IndelMap(
        gap_pos=numpy.array([0, 4, 7], dtype=int),
        gap_lengths=numpy.array([17, 9, 3], dtype=int),
        parent_length=46,
    )

    numpy.testing.assert_allclose(merged.array, exp.array)


@pytest.mark.parametrize(
    "minus", [-1, "-", "-1", -1.0, "Minus", "minus", "MINUS", Strand.MINUS]
)
def test_minus_strand(minus):
    strand = Strand.from_value(minus)
    assert strand is Strand.MINUS
    assert int(strand) == -1
    assert str(strand) == "-"


@pytest.mark.parametrize("plus", [1, "+", "+1", "plus", "Plus", "PLUS", Strand.PLUS])
def test_plus_strand(plus):
    strand = Strand.from_value(plus)
    assert strand is Strand.PLUS
    assert int(strand) == 1
    assert str(strand) == "+"


@pytest.mark.parametrize("null", [None, 0, 0.0, Strand.NONE])
def test_null_strand(null):
    strand = Strand.from_value(null)
    assert strand is Strand.NONE
    assert int(strand) == 0
    assert str(strand) == ""


@pytest.mark.parametrize("invalid", ["unknown_strand_val", "foo"])
def test_invalid_strand_defaults_to_plus(invalid):
    strand = Strand.from_value(invalid)
    assert strand is Strand.PLUS
    assert int(strand) == 1
    assert str(strand) == "+"
