from warnings import warn
from itertools import groupby, chain
from subprocess import Popen, PIPE
from collections import defaultdict, Counter
from typing import Iterable, List, Tuple, Dict, Any, Optional, Callable, Union
from abc import ABCMeta, abstractmethod, abstractproperty
import uuid
import numpy as np
import numpy.typing as npt
import json
from functools import reduce
import re
from dataclasses import dataclass, field
from copy import deepcopy


# (character, code) tuples for encoding special characters in gff3
# percent (%) MUST GO FIRST
ENCODE_SPECIAL_CHARACTERS = (
    ("%", "%25"),
    ("[   |\t]", "%09"),
    ("\n", "%0A"),
    (";", "%3B"),
    ("=", "%3D"),
    ("&", "%26"),
    (",", "%2C"),
)

# (code, character) tuples for decoding special characters in gff3
# percent (%) MUST GO LAST
DECODE_SPECIAL_CHARACTERS = (
    ("%2C", ","),
    ("%26", "&"),
    ("%3D", "="),
    ("%3B", ";"),
    ("%0A", "\n"),
    ("%09", "\t"),
    ("%25", "%"),
)

TRANSLATION = dict(
    ACC="T",
    ACA="T",
    ACG="T",
    AGG="R",
    AGC="S",
    GTA="V",
    AGA="R",
    ACT="T",
    GTG="V",
    AGT="S",
    CCA="P",
    CCC="P",
    GGT="G",
    CGA="R",
    CGC="R",
    TAT="Y",
    CGG="R",
    CCT="P",
    GGG="G",
    GGA="G",
    GGC="G",
    TAA="*",
    TAC="Y",
    CGT="R",
    TAG="*",
    ATA="I",
    CTT="L",
    ATG="M",
    CTG="L",
    ATT="I",
    CTA="L",
    TTT="F",
    GAA="E",
    TTG="L",
    TTA="L",
    TTC="F",
    GTC="V",
    AAG="K",
    AAA="K",
    AAC="N",
    ATC="I",
    CAT="H",
    AAT="N",
    GTT="V",
    CAC="H",
    CAA="Q",
    CAG="Q",
    CCG="P",
    TCT="S",
    TGC="C",
    TGA="*",
    TGG="W",
    TCG="S",
    TCC="S",
    TCA="S",
    GAG="E",
    GAC="D",
    TGT="C",
    GCA="A",
    GCC="A",
    GCG="A",
    GCT="A",
    CTC="L",
    GAT="D",
)


@dataclass(frozen=True)
class Alphabet(set):
    """Alphabet of arbitrary biological sequences

    Examples:
        >>> DNA = Alphabet('DNA', 'ACGT')
        >>> DNA
        Alphabet(name='DNA', members='ACGT')

    Args:
        name (str): Alphabet name
        members (Iterable[str]): Letters of the alphabet
    """

    name: str
    members: Iterable[str]

    def __post_init__(self) -> None:
        super().__init__(self.members)

    def __deepcopy__(self, memo) -> "Alphabet":
        return Alphabet(self, self.name)

    def score(
        self,
        sequence: str,
        match: float = 1.0,
        mismatch: float = -1.0,
        n_chars: int = 100,
    ) -> float:
        """Scores how well a sequence matches an alphabet by summing \
        (mis)matches of sequence letters that are not in the alphabet \
        and (mis)matches of alphabet letters that are not in the sequence.

        Args:
            sequence (str): Sequence string for which to determine how well \
                it fits the alphabet
            match (float, optional): match score. Defaults to 1.0.
            mismatch (float, optional): mismatch score. Defaults to -1.0.
            n_chars (int, optional): number of sequence characters to use in \
                scoring. Large numbers incur a significant computational cost.

        Returns:
            (float): Score of how well a sequence matches the alphabet
        """
        return sum(match if s in self else mismatch for s in sequence[:n_chars]) + sum(
            match if s in sequence[:n_chars] else mismatch for s in self
        )

    def validate(self, sequence: str) -> bool:
        """Determine whether a sequence strictly fits an alphabet

        Args:
            sequence (str): Sequence string

        Returns:
            bool: true if all characters in sequence are in the alphabet
        """
        return sum(1 if s not in self else 0 for s in sequence) == 0

    def complement(self, sequence: str) -> str:
        if self.name != "DNA":
            raise TypeError("Cannot complement non-DNA alphabet")
        dna_complement = dict(zip("acgtnACGTN-?", "tgcanTGCAN-?"))
        return "".join(dna_complement[s] for s in sequence)

    def translate(self, sequence: str) -> str:
        if self.name != "DNA":
            raise TypeError("Cannot translate non-DNA alphabet")
        codons = re.findall("...", sequence.upper())
        return "".join(TRANSLATION.get(codon, "X") for codon in codons)


def alphabet_factory(alphabet):
    return dict(
        DNA=lambda: Alphabet("DNA", "-?ACGNT"),
        AminoAcid=lambda: Alphabet("AminoAcid", "*-?ACDEFGHIKLMNPQRSTVWXY"),
    )[alphabet]


@dataclass(frozen=True)
class Alphabets:
    DNA: Alphabet = field(default_factory=alphabet_factory("DNA"))
    AminoAcid: Alphabet = field(default_factory=alphabet_factory("AminoAcid"))

    def __iter__(self):
        return iter(self.__dict__.values())


alphabets = Alphabets()


def quote_gff3(attribute_value: Union[int, str, float]) -> str:
    """pattern, repl = ENCODE_SPECIAL_CHARACTERS[0]
    quoted_value = re.sub(pattern, repl, attribute_value)
    for pattern, repl in ENCODE_SPECIAL_CHARACTERS[1:]:
        quoted_value = re.sub(pattern, repl, attribute_value)
    return quoted_value"""
    return reduce(
        lambda acc, code: re.sub(code[0], code[1], acc),  # func
        ENCODE_SPECIAL_CHARACTERS,  # iterable
        str(attribute_value),  # initial accumulator
    )


def encode_attribute_value(attribute_value: Iterable[Union[int, str, float]]) -> str:
    """[summary]

    Args:
        attribute_value (Iterable[Union[int, str, float]]): [description]

    Returns:
        str: [description]
    """
    if not isinstance(attribute_value, (list, tuple)):
        attribute_value = [attribute_value]
    return ",".join(quote_gff3(v) for v in attribute_value)


def format_gtf_attribute_string(
    attributes: Dict[str, Iterable[Union[int, str, float]]]
) -> str:
    """[summary]

    Args:
        attributes (Dict[str, Iterable[Union[int, str, float]]]): [description]

    Returns:
        str: [description]
    """
    return "".join(
        f' {key} "{encode_attribute_value(value)}";'
        for key, value in attributes.items()
    ).strip()


def format_gff_attribute_string(
    attributes: Dict[str, Iterable[Union[int, str, float]]]
) -> str:
    """[summary]

    Args:
        attributes (Dict[str, Iterable[Union[int, str, float]]]): [description]

    Returns:
        str: [description]
    """
    partially_formatted = {
        (
            key.capitalize()
            if key in SequenceInterval._predefined_gff3_attributes
            else key
        ): encode_attribute_value(value)
        for key, value in attributes.items()
    }
    partially_formatted["ID"] = partially_formatted.pop("Id")
    return ";".join(f"{key}={value}" for key, value in partially_formatted.items())


def unquote_gff3(attribute_value: str) -> str:
    """[summary]

    Args:
        attribute_value (str): [description]

    Returns:
        str: [description]
    """
    return reduce(
        lambda acc, code: re.sub(code[0], code[1], acc),  # func
        DECODE_SPECIAL_CHARACTERS,  # iterable
        attribute_value,  # initial
    )


def decode_attribute_value(attribute_value: str) -> List[str]:
    """[summary]

    Args:
        attribute_value (str): [description]

    Returns:
        List[str]: [description]
    """
    return [unquote_gff3(v) for v in attribute_value.split(",")]


def parse_gtf_attribute_string(gtf_attribute_string: str) -> Dict[str, List[str]]:
    """[summary]

    Args:
        gtf_attribute_string (str): [description]

    Returns:
        Dict[str, List[str]]: [description]
    """
    attributes = defaultdict(list)
    for string_part in gtf_attribute_string.split(";"):
        string_part = string_part.strip()
        if not string_part:
            continue
        try:
            key, value = string_part.split(" ", maxsplit=1)
        except Exception as e:
            print(gtf_attribute_string, string_part)
            raise Exception(e)
        attributes[key].append(value.strip('"'))
    return attributes


def parse_gff_attribute_string(
    gff_attribute_string: str, case_sensitive_attribute_keys: bool = False
) -> Dict[str, List[str]]:
    """[summary]
    https://github.com/The-Sequence-Ontology/Specifications/blob/master/gff3.md
    See "Column 9: Attributes"
    Args:
        gff_attribute_string ([type]): [description]
    """
    attributes = defaultdict(list)
    for string_part in gff_attribute_string.split(";"):
        if not string_part:
            continue
        try:
            key, value = string_part.split("=", maxsplit=1)
        except Exception as e:
            print(gff_attribute_string, string_part)
            raise Exception(e)
        # The gff spec lists the predefined attribute fields as starting with
        # a capital letter, but we process in lowercase so we don't miss
        # anything from poorly formatted files. When writing to gff we convert
        # back to a capital
        # EXCEPT FOR THE ID ATTRIBUTE, since lowercase id is reserved in python
        if key != "ID":
            key = key.lower()
        # First eight columns have predefined names which can collide with what
        # is in the 9th column. Solution is to prefix collisions in the 9th
        # column with underscore. E.g. 'score' becomes '_score' because 'score'
        # is the predefined name of the 6th column
        if key in SequenceInterval._fixed_gff3_fields:
            key = f"_{key}"
        for value_part in decode_attribute_value(value):
            attributes[key].append(value_part)
    return attributes


class SequenceAnnotation:
    def __init__(self, sequence: Optional["Sequence"] = None) -> None:
        """[summary]

        Args:
            sequence (Optional[Sequence], optional): [description]. Defaults\
                 to None.
        """
        if sequence:
            sequence.annotation = self
        self.sequence = sequence
        self._intervals = dict()
        self._gff_headers = list()

    def __getitem__(self, ID: str) -> "SequenceInterval":
        return self._intervals[ID]

    def __setitem__(self, ID: str, interval: "SequenceInterval"):
        if ID in self._intervals:
            warn(f"Turning duplicate ID {ID} into unique ID")
            interval._original_ID = ID
            modifier = 0
            new_ID = ID
            while new_ID in self._intervals:
                modifier += 1
                new_ID = f"{ID}_{modifier}"
            ID = new_ID
            interval._ID = ID
        self._intervals[ID] = interval

    def __iter__(self):
        try:
            yield from self._intervals.values()
        except StopIteration:
            return

    @property
    def intervals(self):
        return list(self)

    @classmethod
    def from_gtf(
        cls,
        filename: Optional[str] = None,
        string: Optional[str] = None,
        sequence: Optional["Sequence"] = None,
    ) -> "SequenceAnnotation":
        """[summary]

        Raises:
            IndexError: [description]
            IndexError: [description]

        Returns:
            [type]: [description]
        """
        assert filename or string
        assert not (filename and string)
        sequence_annotation = cls(sequence=sequence)
        header = True

        # start with just reading all intervals
        if filename:
            with open(filename) as filehandle:
                string = filehandle.read()
        for line_number, line in enumerate(string.split("\n")):
            line = line.strip()
            if not line:
                continue
            if line[0] == "#":
                if header:
                    sequence_annotation._gff_headers.append(line)
                continue
            else:
                header = False
            interval = SequenceInterval.from_gtf_line(
                gtf_line=line, line_number=line_number
            )
            interval._container = sequence_annotation
            sequence_annotation[interval.ID] = interval
        # fix missing gene and transcript intervals
        transcript_child_counter = Counter()
        new_intervals = dict()
        for interval in sequence_annotation:
            gene_id = interval.gff_attributes["gene_id"][0]
            transcript_id = interval.gff_attributes["transcript_id"][0]
            interval_type = interval.interval_type
            id_tuple = (gene_id, transcript_id, interval_type)
            child_count = transcript_child_counter[id_tuple]
            transcript_child_counter.update([id_tuple])
            interval._ID = f"{transcript_id}.{interval_type}_{child_count}"
            if transcript_id not in new_intervals:
                # new transcript interval
                transcript_interval = deepcopy(interval)
                transcript_interval._container = interval._container
                transcript_interval._ID = transcript_id
                transcript_interval.interval_type = "mRNA"
                transcript_interval.parent = [gene_id]
                # new gene interval
                gene_interval = deepcopy(interval)
                gene_interval._container = interval._container
                gene_interval._ID = gene_id
                gene_interval.interval_type = "gene"
                gene_interval.parent = None

                new_intervals[transcript_id] = transcript_interval
                new_intervals[gene_id] = gene_interval

            interval.parent = [transcript_id]
            new_intervals[interval.ID] = interval
        sequence_annotation._intervals = new_intervals

        # set children
        for interval in sequence_annotation:
            if interval.parent:
                for parent_ID in interval.parent:
                    try:
                        parent = sequence_annotation[parent_ID]
                    except IndexError:
                        raise IndexError(
                            "Interval {interval.ID} is listing {parent_ID} "
                            "as Parent, but parent could not be found."
                        )
                    parent._children.append(interval.ID)

        # fix gene and transcript start and stop coordinates
        genes = sequence_annotation.groupby("interval_type")["gene"]
        for gene in genes:
            # fix gene first
            start = 10e9
            end = 0
            for child in gene.children:
                start = min(start, child.start)
                end = max(end, child.end)
            gene.start = start
            gene.end = end

            # fix transcripts
            transcripts = gene.children.groupby("interval_type")["mRNA"]
            for transcript in transcripts:
                start = 10e9
                end = 0
                for child in transcript.children:
                    start = min(start, child.start)
                    end = max(end, child.end)
                transcript.end = end
                transcript.start = start

        return sequence_annotation

    def to_gtf(self) -> str:
        return "\n".join(interval.to_gtf_line() for interval in self)

    @classmethod
    def from_gff(
        cls,
        filename: Optional[str] = None,
        string: Optional[str] = None,
        sequence: Optional["Sequence"] = None,
        link_parents: bool = True,
    ) -> "SequenceAnnotation":
        """[summary]

        Args:
            filename ([type], optional): [description]. Defaults to None.
            string ([type], optional): [description]. Defaults to None.
            sequence ([type], optional): [description].
                Defaults to None.

        Returns:
            [type]: [description]
        """
        assert filename or string
        assert not (filename and string)
        sequence_annotation = cls(sequence=sequence)
        header = True
        if filename:
            with open(filename) as filehandle:
                string = filehandle.read()
        for line_number, line in enumerate(string.split("\n")):
            line = line.strip()
            if not line:
                continue
            if line == "##FASTA":
                break
            if line[0] == "#":
                if header:
                    sequence_annotation._gff_headers.append(line)
                continue
            else:
                header = False

            interval = SequenceInterval.from_gff_line(
                gff_line=line, line_number=line_number
            )
            interval._container = sequence_annotation
            sequence_annotation[interval.ID] = interval

        if link_parents:
            for interval in sequence_annotation:
                if interval.parent:
                    for parent_ID in interval.parent:
                        try:
                            parent = sequence_annotation[parent_ID]
                        except IndexError:
                            raise IndexError(
                                "Interval {interval.ID} is listing {parent_ID}"
                                " as Parent, but parent could not be found."
                            )
                        parent._children.append(interval.ID)

        return sequence_annotation

    def to_gff(self) -> str:
        """[summary]

        Returns:
            str: [description]
        """
        return "\n".join(interval.to_gff_line() for interval in self)

    @classmethod
    def from_json(
        cls,
        filename: Optional[str] = None,
        string: Optional[str] = None,
        sequence: Optional["Sequence"] = None,
    ) -> "SequenceAnnotation":
        """[summary]"""
        assert filename or string
        assert not (filename and string)
        if filename:
            with open(filename) as filehandle:
                string = filehandle.read()

        sequence_annotation = cls(sequence=sequence)

        gene_dicts = json.loads(string)
        assert isinstance(gene_dicts, list)

        for top_dict in gene_dicts:
            child_dicts = top_dict.pop("children", list())
            top_interval = SequenceInterval.from_dict(interval_dict=top_dict)
            top_interval._container = sequence_annotation
            sequence_annotation[top_interval.ID] = top_interval
            for child_dict in child_dicts:
                child_interval = SequenceInterval.from_dict(interval_dict=child_dict)
                child_interval._container = sequence_annotation
                sequence_annotation[child_interval.ID] = child_interval
        for interval in sequence_annotation:
            if interval.parent:
                for parent_ID in interval.parent:
                    try:
                        parent = sequence_annotation[parent_ID]
                    except IndexError:
                        raise IndexError(
                            "Interval {interval.ID} is listing {parent_ID} "
                            "as Parent, but parent could not be found."
                        )
                    parent._children.append(interval.ID)
        return sequence_annotation

    def to_json(self, indent: Optional[int] = None) -> str:
        """[summary]

        Returns:
            str: [description]
        """
        interval_dicts = [interval.to_dict() for interval in self]
        return json.dumps(interval_dicts, indent=indent)

    def add(self, interval: "SequenceInterval"):
        self[interval.ID] = interval

    def pop(self, value: str) -> "SequenceInterval":
        return self._intervals.pop(value)

    def groupby(self, key="seqid") -> Dict[str, "SequenceAnnotation"]:
        grouped = defaultdict(SequenceAnnotation)
        for interval in self:
            grouped[interval[key]][interval.ID] = interval
            interval._container = self
        return grouped

    def filter(self, filter_func: Callable) -> "SequenceAnnotation":
        result = SequenceAnnotation()
        for interval in self:
            if filter_func(interval):
                result[interval.ID] = interval
        return result


class SequenceInterval:
    _predefined_gff3_attributes = (
        "ID",
        "name",
        "alias",
        "parent",
        "target",
        "gap",
        "derives_from",
        "note",
        "dbxref",
        "ontology_term",
        "is_circular",
    )
    _fixed_gff3_fields = (
        "seqid",
        "source",
        "interval_type",
        "start",
        "end",
        "score",
        "strand",
        "phase",
    )
    _gtf_interval_types = dict(mRNA="transcript")

    def __init__(
        self,
        ID: Optional[str] = None,
        seqid: Optional[str] = None,
        source: Optional[str] = None,
        interval_type: Optional[str] = None,
        start: Optional[int] = None,
        end: Optional[int] = None,
        score: Optional[float] = None,
        strand: Optional[str] = None,
        phase: Optional[str] = None,
        children: Optional[List[str]] = None,
        container: Optional[SequenceAnnotation] = None,
        **kwargs,
    ):
        """[summary]

        Args:
            ID (Optional[str], optional): [description]. Defaults to None.
            seqid (Optional[str], optional): [description]. Defaults to None.
            source (Optional[str], optional): [description]. Defaults to None.
            interval_type (Optional[str], optional): [description]. Defaults
                to None.
            start (Optional[int], optional): [description]. Defaults to None.
            end (Optional[int], optional): [description]. Defaults to None.
            score (Optional[float], optional): [description]. Defaults to None.
            strand (Optional[str], optional): [description]. Defaults to None.
            phase (Optional[str], optional): [description]. Defaults to
                None.
            children (Optional[List], optional): [description]. Defaults to
                None.
            container (Optional[SequenceAnnotation], optional): [description].
                Defaults to None.
        """
        # interval ID is a property (see below) with getter and setter
        self._ID = ID
        self._original_ID = ID

        # Standard gff fields
        self.seqid = seqid
        self.source = source
        self.interval_type = interval_type
        self.start = start
        self.end = end
        self.score = score
        self.strand = strand
        self.phase = phase

        # Set attributes with predefined meanings in the gff spec to None
        for attr in self._predefined_gff3_attributes:
            if attr == "ID":
                continue
            self[attr] = kwargs.get(attr, None)

        # Any additional attributes
        for key, value in kwargs.items():
            self[key] = value

        # Additional fields, used internally
        self._container = container
        if children is None:
            children = []
        self._children = children

    def __repr__(self):
        return (
            f"<SequenceInterval type={self.interval_type} "
            f"ID={self.ID} "
            f"loc={self.seqid}..{self.start}..{self.end}..{self.strand} "
            f"at {hex(id(self))}>"
        )

    def __getitem__(self, key):
        return self.__dict__[key]

    def __setitem__(self, key, value):
        self.__dict__[key] = value

    def __deepcopy__(self, memo):
        new_interval = SequenceInterval()
        for key, value in vars(self).items():
            # _container is strictly a reference, do not deepcopy
            if key == "_container":
                new_interval[key] = value
            else:
                new_interval[key] = deepcopy(value)
        return new_interval

    @property
    def ID(self):
        return self._ID

    @ID.setter
    def ID(self, value):
        old_ID = self._ID
        if not self._original_ID:
            self._original_ID = old_ID
        self._ID = value
        for child in self.children:
            if child.parent:
                child.parent = [value if p == old_ID else p for p in child.parent]
        if self._container:
            self._container[value] = self._container.pop(old_ID)

    @property
    def gff_attributes(self) -> Dict[str, str]:
        return {
            attr: self[attr]
            for attr in self.__dict__
            if attr not in self._fixed_gff3_fields  # skip column 1-8 in gff3
            and attr
            not in (
                "_children",
                "_container",
                "_ID",
                "_original_ID",
            )  # internal use only
            and self[attr] is not None  # no empty attributes
        }

    @property
    def gtf_attributes(self) -> Dict[str, str]:
        def get_gtf_type(gff_type):
            return self._gtf_interval_types.get(gff_type, gff_type)

        parent_ids = {
            f"{get_gtf_type(parent.interval_type)}_id": parent.ID
            for parent in self._get_parents()
        }
        return {**self.gff_attributes, **parent_ids}

    @property
    def parents(self) -> SequenceAnnotation:
        ann = SequenceAnnotation()
        for interval in self._get_parents():
            if interval == self:
                continue
            ann[interval.ID] = interval
        return ann

    @property
    def children(self) -> SequenceAnnotation:
        ann = SequenceAnnotation()
        for interval in self._get_children():
            if interval == self:
                continue
            ann[interval.ID] = interval
        return ann

    @classmethod
    def from_gtf_line(
        cls, gtf_line: Optional[str] = None, line_number: Optional[int] = None
    ) -> "SequenceInterval":
        """[summary]

        Returns:
            [type]: [description]

        Yields:
            [type]: [description]
        """
        return cls.from_gff_line(gtf_line, line_number, parse_gtf_attribute_string)

    def to_gtf_line(self) -> str:
        """[summary]

        Returns:
            str: [description]
        """
        interval_type = self._gtf_interval_types.get(
            self.interval_type, self.interval_type
        )
        return "\t".join(
            [
                self.seqid,
                self.source,
                interval_type,
                str(self.start),
                str(self.end),
                str(self.score),
                self.strand,
                str(self.phase),
                format_gtf_attribute_string(self.gtf_attributes),
            ]
        )

    @classmethod
    def from_gff_line(
        cls,
        gff_line: Optional[str] = None,
        line_number: Optional[int] = None,
        attribute_parser: Callable = parse_gff_attribute_string,
    ) -> "SequenceInterval":
        """[summary]

        Args:
            gff_line (Optional[str], optional): [description]. Defaults
                to None.
            line_number (Optional[int], optional): [description]. Defaults
                to None.

        Returns:
            [type]: [description]
        """
        gff_parts = gff_line.split("\t")
        assert len(gff_parts) == 9, gff_parts
        seqid, source, interval_type, start, end, score, strand, phase = gff_parts[:8]
        try:
            start = int(start)
            end = int(end)
        except ValueError:
            error = "GFF start and end fields must be integer"
            if line_number:
                error = f"{error}, gff line {line_number}"
            raise ValueError(error)

        if score != ".":
            try:
                score = float(score)
            except ValueError:
                error = "GFF score field must be a float"
                if line_number:
                    error = f"{error}, gff line {line_number}"
                raise ValueError(error)

        if strand not in ("+", "-", "."):
            error = 'GFF strand must be one of "+", "-" or "."'
            if line_number:
                error = f"{error}, gff line {line_number}"
            raise ValueError(error)

        if phase not in ("0", "1", "2", "."):
            error = 'GFF phase must be one of "0", "1", "2" or "."'
            if line_number:
                error = f"{error}, gff line {line_number}"
            raise ValueError(error)
        elif phase != ".":
            phase = int(phase)

        # Disable phase checking of CDS for now...
        # if interval_type == 'CDS' and phase not in ('0', '1', '2'):
        #     error = 'GFF intervals of type CDS must have phase of\
        #         "0", "1" or "2"'
        #     if line_number:
        #         error = f'{error}, gff line {line_number}'
        #         raise ValueError(error)

        attributes = attribute_parser(gff_parts[8])

        ID = attributes.pop("ID", [str(uuid.uuid4())])[0]

        return cls(
            seqid=seqid,
            source=source,
            interval_type=interval_type,
            start=start,
            end=end,
            score=score,
            strand=strand,
            phase=phase,
            ID=ID,
            **attributes,
        )

    def to_gff_line(self) -> str:
        """[summary]

        Returns:
            str: [description]
        """
        attributes = defaultdict(list)
        attributes.update(self.gff_attributes)
        for attribute in self._predefined_gff3_attributes:
            attribute_value = getattr(self, attribute)
            if attribute_value is not None:
                attributes[attribute] = attribute_value

        attributes["ID"] = [attributes["ID"]]

        return "\t".join(
            [
                self.seqid,
                self.source,
                self.interval_type,
                str(self.start),
                str(self.end),
                str(self.score),
                self.strand,
                str(self.phase),
                format_gff_attribute_string(attributes),
            ]
        )

    @classmethod
    def from_dict(cls, interval_dict: Dict[str, Any]) -> "SequenceInterval":
        """[summary]
        Args:
            interval_dict

        Returns:
            [type]: [description]
        """
        attributes = interval_dict.pop("attributes", dict())
        return cls(**interval_dict, **attributes)

    def to_dict(self, include_children: bool = False) -> Dict[str, Any]:
        """[summary]

        Returns:
            Dict[str, Any]: [description]
        """
        attributes = dict(**self.attributes)
        attributes.pop("ID")
        interval_dict = dict(
            ID=self.ID,
            seqid=self.seqid,
            source=self.source,
            interval_type=self.interval_type,
            start=self.start,
            end=self.end,
            score=self.score,
            strand=self.strand,
            phase=self.phase,
            attributes=attributes,
        )
        if include_children:
            children = [child.to_dict() for child in self.children[1:]]
            interval_dict["children"] = children
        return interval_dict

    def to_json(
        self, include_children: bool = False, indent: Optional[int] = None
    ) -> str:
        """[summary]

        Args:
            include_children (bool, optional): [description]. Defaults to \
                False.

        Returns:
            str: [description]
        """
        return json.dumps(
            self.to_dict(include_children=include_children), indent=indent
        )

    def _get_children(self, _visited: Optional[set] = None):
        if _visited is None:
            _visited = set()
        if self not in _visited:
            yield self
            _visited.add(self)
        if not self._children:
            return
        for child_ID in self._children:
            child = self._container[child_ID]
            yield from child._get_children(_visited=_visited)

    def _get_parents(self, _visited: Optional[set] = None):
        if _visited is None:
            _visited = set()
        if self not in _visited:
            yield self
            _visited.add(self)
        if not self.parent:
            return
        for parent_ID in self.parent:
            parent = self._container[parent_ID]
            yield from parent._get_parents(_visited=_visited)


@dataclass
class Sequence:
    """Container for a single biological sequence

    Examples:
        >>> s1 = Sequence('test_dna', 'ACGATCGACTAGCA')
        >>> s1
        Sequence(header='test_dna', \
alphabet=Alphabet(name='DNA', members='-?ACGNT'))
        >>> s2 = Sequence('test_aa', 'QAPISAIWPOIWQ*')
        >>> s2
        Sequence(header='test_aa', \
alphabet=Alphabet(name='AminoAcid', members='*-?ACDEFGHIKLMNPQRSTVWXY'))

    Returns:
        [type]: [description]
    """

    header: str = None
    sequence: str = field(repr=False, default=None)
    alphabet: Alphabet = None
    annotation: Optional[SequenceAnnotation] = field(
        default_factory=SequenceAnnotation, repr=False
    )

    def __post_init__(self):
        if self.alphabet is not None:
            return
        if self.sequence is None:
            self.alphabet = alphabets.DNA
        else:
            self.alphabet = sorted(
                alphabets, key=lambda alphabet: alphabet.score(self.sequence)
            ).pop()

    def __getitem__(self, key):
        return Sequence(self.header, self.sequence[key])

    def __len__(self):
        """Length of the sequence

        Examples:
            >>> s = Sequence('test_dna', 'ACGTA')
            >>> len(s)
            5
        """
        return len(self.sequence)

    @property
    def reverse(self):
        return Sequence(self.header, self.sequence[::-1])

    @property
    def complement(self):
        return Sequence(self.header, self.alphabet.complement(self.sequence))

    @property
    def reverse_complement(self):
        return Sequence(self.header, self.alphabet.complement(self.sequence[::-1]))

    @property
    def amino_acids(self):
        if self.alphabet.name == "AminoAcid":
            return self
        else:
            return Sequence(self.header, self.alphabet.translate(self.sequence))

    def to_dict(self) -> Dict[str, str]:
        """Make dictionary with header and sequence elements

        Examples:
            >>> s = Sequence('test', 'ACGTA')
            >>> s.to_dict()
            {'header': 'test', 'sequence': 'ACGTA'}

        Returns:
            Dict[str, str]: sequence dictionary
        """
        return dict(header=self.header, sequence=self.sequence)

    @classmethod
    def from_fasta(cls, string: str) -> "Sequence":
        """Create a sequence object from a fasta formatted file. _single sequence only_

        Examples:
            >>> fasta_string = '>test\\nACGT'
            >>> Sequence.from_fasta(fasta_string)
            Sequence(header='test', \
alphabet=Alphabet(name='DNA', members='-?ACGNT'))

        Arguments:
            string (str)

        Returns:
            Sequence
        """
        lines = string.strip().split("\n")
        header = lines[0][1:]
        sequence = "".join(lines[1:])
        return cls(header, sequence)

    def to_fasta(self, linewidth: int = 80) -> str:
        """Make fasta formatted sequence entry

        Returns:
            str: sequence in fasta format
        """
        sequence_lines = "\n".join(re.findall(f".{{1,{linewidth}}}", self.sequence))
        return f">{self.header}\n{sequence_lines}"


class FastaParseError(Exception):
    pass


class SequenceReader:
    def __init__(
        self, string: str = None, filename: str = None, filetype: str = None
    ) -> None:
        """Iterator over fasta/json formatted sequence strings

        Args:
            string (str): fasta/json formatted string
            filename (str): fasta/json file name
            filetype (str): fasta or json

        Examples:
            >>> fasta_string = '>1\\nACGC\\n>2\\nTGTGTA\\n'
            >>> fasta_reader = SequenceReader(string=fasta_string, \
filetype='fasta')
            >>> next(fasta_reader)
            Sequence(header='1', \
alphabet=Alphabet(name='DNA', members='-?ACGNT'))
        """
        assert bool(string) ^ bool(
            filename
        ), "Must specify exactly one of string or filename"  # exclusive OR
        if filename:
            with open(filename, "r") as filehandle:
                string = filehandle.read().strip()

        self.string = string

        if filetype == "fasta":
            self._iter = self._fasta_iter
        elif filetype == "json":
            self._iter = self._json_iter
        else:
            raise ValueError(f'filetype "{filetype}" is not supported')

    def __iter__(self) -> Iterable[Sequence]:
        """Iterate over header,sequence tuples

        Returns:
            Iterable[Sequence]: [description]

        Yields:
            Iterable[Sequence]: [description]
        """
        try:
            yield from self._iter()
        except StopIteration:
            return

    def __next__(self) -> Sequence:
        """Next header and sequence in the iterator

        Returns:
            Tuple[str, str]: [description]
        """
        return next(self._iter())

    def _fasta_iter(self) -> Iterable[Sequence]:
        if self.string[0] != ">":
            raise FastaParseError(
                'First character in fasta format\
                 must be ">"'
            )
        fasta_iter = (
            x
            for _, x in groupby(
                self.string.strip().split("\n"), lambda line: line[:1] == ">"
            )
        )
        for header in fasta_iter:
            header = next(header)[1:].strip()
            seq = "".join(s.strip() for s in next(fasta_iter))
            yield Sequence(header, seq)

    def _json_iter(self) -> Iterable[Sequence]:
        for entry in json.loads(self.string):
            yield Sequence(entry["header"], entry["sequence"])


class BatchSequenceReader(SequenceReader):
    def __init__(
        self,
        string: str = None,
        filename: str = None,
        filetype: str = None,
        batchsize: int = 10,
    ) -> None:
        """[summary]

        Args:
            string (str, optional): [description]. Defaults to None.
            filename (str, optional): [description]. Defaults to None.
            filetype (str, optional): [description]. Defaults to None.
            batchsize (int, optional): [description]. Defaults to 10.

        Returns:
            [type]: [description]

        Yields:
            [type]: [description]
        """
        super().__init__(string, filename, filetype)
        self.batchsize = batchsize
        self._currentbatch = SequenceCollection()

    def __iter__(self) -> Iterable["SequenceCollection"]:
        for s in self._iter():
            self._currentbatch[s.header] = s
            if len(self._currentbatch) == self.batchsize:
                yield self._currentbatch
                self._currentbatch = SequenceCollection()

    def __next__(self) -> "SequenceCollection":
        currentbatch = self._currentbatch
        self._currentbatch = SequenceCollection()
        if len(currentbatch) == self.batchsize:
            return currentbatch
        for s in self._iter():
            currentbatch[s.header] = s
            if len(currentbatch) == self.batchsize:
                yield currentbatch


SequenceIndexKey = Union[int, List[int], slice]


class SequenceIndex:
    def __init__(
        self,
        sequence_collection: Union["SequenceCollection", "MultipleSequenceAlignment"],
    ):
        self.sequence_collection = sequence_collection

    def __getitem__(self, key: SequenceIndexKey):
        if isinstance(key, int):
            key = [key]
        elif isinstance(key, slice):
            start = key.start if key.start is not None else 0
            stop = key.stop if key.stop is not None else len(self.sequence_collection)
            step = key.step if key.step is not None else 1
            key = range(start, stop, step)
        elif not isinstance(key, list):
            raise TypeError(f"SequenceIndex key must be of type f{SequenceIndexKey}")
        new_seq_col = self.sequence_collection.__class__()
        for k in key:
            header = self.sequence_collection.headers[k]
            new_seq_col[header] = self.sequence_collection[header].sequence
        return new_seq_col


class AbstractSequenceCollection(metaclass=ABCMeta):
    """
    (Partially) Abstract Base Class for sequence collections.
    Classes extending from this baseclass should override
    `__setitem__`, `__getitem__`, `__delitem__`, `headers`, and `n_seqs`.

    If the above methods are implemented, this automatically enables the
    following methods: `from_fasta`, `to_fasta`, `from_json`, `to_json`.

    Args:
        sequences (Optional[Iterable[Tuple[str, str]]], optional):
            Iterable of (header, sequence) tuples. Defaults to None.
        sequence_annotation (Optional[SequenceAnnotation]):
                picea SequenceAnnotation object. Defaults to None.

    Raises:
        NotImplementedError: Abstract Base Class can not be initialized
            and serves as a template only
    """

    @abstractmethod
    def __init__(
        self,
        sequences: Optional[Iterable[Sequence]] = None,
        sequence_annotation: Optional["SequenceAnnotation"] = None,
    ) -> None:
        raise NotImplementedError("Not implemented in base class")

    @abstractmethod
    def __setitem__(self, header: str, seq: str) -> None:
        raise NotImplementedError()

    @abstractmethod
    def __getitem__(self, header: str) -> Sequence:
        raise NotImplementedError()

    @abstractmethod
    def __delitem__(self, header: str) -> None:
        raise NotImplementedError()

    def __iter__(self) -> Iterable[Sequence]:
        for header in self.headers:
            yield self[header]

    def __len__(self) -> int:
        return len(self.headers)

    @abstractproperty
    def headers(self) -> List[str]:
        """List of sequences headers.
        Overridden in subclasses.

        Raises:
            NotImplementedError

        Returns:
            List[str]: List of sequence headers
        """
        raise NotImplementedError()

    @property
    def iloc(self) -> SequenceIndex:
        """[summary]

        Returns:
            SequenceIndex: [description]
        """
        return SequenceIndex(self)

    @property
    def sequences(self) -> List[str]:
        """List of sequences without headers

        Returns:
            List[str]: list of sequences
        """
        return [self[header].sequence for header in self.headers]

    @abstractproperty
    def n_seqs(self) -> int:
        """Return the number of sequences in the collection.
        Overridden in subclasses

        Raises:
            NotImplementedError

        Returns:
            int: number of sequences
        """
        raise NotImplementedError()

    @classmethod
    def from_sequence_iter(
        cls, sequence_iter: Iterable[Sequence]
    ) -> "SequenceCollection":
        """[summary]

        Raises:
            NotImplementedError: [description]

        Returns:
            [type]: [description]
        """
        sequencecollection = cls()
        for seq in sequence_iter:
            sequencecollection[seq.header] = seq.sequence
        return sequencecollection

    @classmethod
    def from_fasta(
        cls,
        filename: str = None,
        string: str = None,
    ) -> "SequenceCollection":
        """Parse a fasta formatted string into a SequenceCollection object

        Keyword Arguments:
            filename {String} -- filename string (default: {None})
            string {String} -- fasta formatted string (default: {None})

        Returns:
            SequenceCollection -- SequenceCollection instance
        """
        sequencecollection = cls()

        for seq in SequenceReader(string=string, filename=filename, filetype="fasta"):
            sequencecollection[seq.header] = seq.sequence
        return sequencecollection

    def to_fasta(self, linewidth: int = 80) -> str:
        """Get a fasta-formatted string of the sequence collection

        Returns:
            str: Multi-line fasta-formatted string
        """
        return "\n".join([seq.to_fasta(linewidth=linewidth) for seq in self])

    @classmethod
    def from_json(
        cls, filename: Optional[str] = None, string: Optional[str] = None
    ) -> "SequenceCollection":
        """[summary]

        Keyword Arguments:
            string {String} -- JSON formatted string

        Returns:
            SequenceCollection -- SequenceCollection instance
        """
        sequencecollection = cls()

        for seq in SequenceReader(string=string, filename=filename, filetype="json"):
            sequencecollection[seq.header] = seq.sequence

        return sequencecollection

    def to_json(self, indent: Optional[int] = None) -> str:
        """[summary]

        Returns:
            str: [description]
        """
        gene_dicts = [seq.to_dict() for seq in self]
        return json.dumps(gene_dicts, indent=indent)

    @abstractmethod
    def pop(self, header: str) -> Sequence:
        """[summary]

        Args:
            header (str): [description]

        Returns:
            Sequence: [description]
        """
        raise NotImplementedError()

    def batch_rename(self, rename_func: Callable[[str], str]) -> None:
        """Rename all headers by calling `rename_func` on each header

        Args:
            rename_func (Callable): [description]
        """
        for header in self.headers:
            s: Sequence = self.pop(header)
            s.header = rename_func(s.header)
            self[s.header] = s.sequence


class SequenceCollection(AbstractSequenceCollection):
    """
    A container for multiple (unaligned) DNA or amino acid sequences
    """

    def __init__(
        self: "SequenceCollection",
        sequences: Iterable[Tuple[str, str]] = None,
        sequence_annotation: "SequenceAnnotation" = None,
    ):
        self._collection = dict()
        if sequences:
            for header, sequence in sequences:
                self[header] = sequence
        self.sequence_annotation = sequence_annotation

    def __setitem__(self, header: str, seq: str) -> None:
        if header in self._collection:
            warn(f'Turning duplicate header "{header}" into unique header')
            new_header = header
            modifier = 0
            while new_header in self.headers:
                modifier += 1
                new_header = f"{header}_{modifier}"
            header = new_header
        self._collection[header] = seq

    def __getitem__(self, header: str) -> Sequence:
        sequence = self._collection[header]
        return Sequence(header, sequence)

    def __delitem__(self, header: str) -> None:
        del self._collection[header]

    @property
    def headers(self) -> List[str]:
        return list(self._collection.keys())

    @property
    def n_seqs(self) -> int:
        return len(self._collection.keys())

    def align(self, method: str = "mafft", method_kwargs: Dict[str, str] = dict()):
        """[summary]

        Args:
            method (str, optional): [description]. Defaults to 'mafft'.
            method_kwargs (Dict[str, str], optional): [description]. \
                Defaults to dict().

        Returns:
            [type]: [description]
        """
        fasta = self.to_fasta()
        command = [method, *chain(*method_kwargs.items()), "-"]
        process = Popen(command, stdin=PIPE, stdout=PIPE, stderr=PIPE)
        stdout, stderr = process.communicate(input=fasta.encode())
        aligned_fasta = stdout.decode().strip()
        return MultipleSequenceAlignment.from_fasta(string=aligned_fasta)

    def pop(self, header: str) -> Sequence:
        sequence = self._collection.pop(header)
        return Sequence(header, sequence)


class MultipleSequenceAlignment(SequenceCollection):
    def __init__(
        self,
        sequences: Optional[Iterable[Sequence]] = None,
        sequence_annotation: Optional["SequenceAnnotation"] = None,
    ) -> None:
        """
        A container for multiple aligned DNA or amino acid sequences
        """
        self._collection = np.empty((0, 0), dtype="uint8")
        self._header_idx = dict()
        if sequences:
            for seq in sequences:
                self[seq.header] = seq.sequence
        # if sequence_annotation:
        #     sequence_annotation.sequence_collection = self
        self.sequence_annotation = sequence_annotation

    def __setitem__(self, header: str, seq: str) -> None:
        seq = seq.encode()
        if header in self._header_idx:
            warn(f'Turning duplicate header "{header}" into unique header')
            new_header = header
            modifier = 0
            while new_header in self._header_idx:
                modifier += 1
                new_header = f"{header}_{modifier}"
            header = new_header
        n_seq, n_char = self._collection.shape
        if n_seq == 0:
            self._collection = np.array([[*seq]], dtype="uint8")
        else:
            len_diff = len(seq) - n_char

            filler1 = np.array([[*b"-"] * len_diff], dtype="uint8")
            arr = np.hstack((self._collection, np.repeat(filler1, n_seq, axis=0)))

            filler2 = np.array([*b"-"] * -len_diff, dtype="uint8")
            new_row = np.array([[*seq, *filler2]], dtype="uint8")

            arr = np.vstack((arr, new_row))
            self._collection = arr
        self._header_idx[header] = n_seq

    def __getitem__(self, header: str) -> Sequence:
        idx = self._header_idx[header]
        n_chars = self._collection.shape[1]
        sequence = self._collection[idx].view(f"S{n_chars}")[0].decode()
        return Sequence(header, sequence)

    def __delitem__(self, header: str) -> None:
        """WIP!

        Args:
            header (str): [description]
        """
        idx = self._header_idx[header]
        self._collection = np.delete(self._collection, idx, axis=0)
        # del

    @property
    def headers(self) -> List[str]:
        return list(self._header_idx.keys())

    @property
    def n_seqs(self) -> int:
        return self._collection.shape[0]

    @property
    def n_chars(self) -> int:
        return self._collection.shape[1]

    @property
    def shape(self) -> int:
        return self._collection.shape

    def pop(self, header: str) -> Sequence:
        pop_idx = self._header_idx[header]
        n_chars = self._collection.shape[1]
        sequence = self._collection[pop_idx].view(f"S{n_chars}")[0].decode()
        del self._header_idx[header]
        self._header_idx = {
            h: (idx if idx < pop_idx else idx - 1)
            for h, idx in self._header_idx.items()
        }
        self._collection = np.delete(self._collection, (pop_idx,), axis=0)
        return Sequence(header, sequence)

    def pairwise_distances(
        self, distance_measure: str = "identity"
    ) -> npt.NDArray[np.float64]:
        pass
