# Copyright 2024 Google LLC.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Utilities for working with transcripts."""

import collections
import copy
import dataclasses
import functools
import sys
from typing import Any

from alphagenome.data import genome
import pandas as pd


MITOCHONDRIAL_CHROMS = ['M', 'chrM', 'MT']


@dataclasses.dataclass(frozen=True)
class Transcript:
  """Represents transcript object containing attributes from a GTF file.

  A transcript is a region of DNA that encodes a single RNA molecule. The
  Transcript dataclass contains attributes that describe the structure and
  content of a transcript, namely:

  Attributes:
    exons: A list of `genome.Interval`s representing exons within transcript.
      Each `Transcript` must contain exons.
    cds: An optional list of `genome.Interval`s representing coding sequences
      (CDS) within a transcript. CDS include start codon and exclude top codon.
    start_codon: An optional list of `genome.Interval`s representing a single
      start codon. Start codons can be split by introns, therefore might have
      more than one genomic interval. Some coding transcripts are missing start
      codons, e.g., ENST00000455638.6.
    stop_codon: An optional list of `genome.Interval`s representing a single
      stop codon. Stop codon can be split by introns, therefore might have more
      than one genomic interval. Some transcripts coding transcripts are missing
      stop codons, e.g., ENST00000574051.5.
    transcript_id: An optional string representing a transcript id.
    gene_id: An optional string representing a protein id.
    protein_id: An optional string representing a protein id which is encoded by
      the transcript.
    uniprot_id: An optional UniprotKB-AC id string.
    info: a dictionary of additional information on a transcript.
    chromosome: chromosome name on which the transcript is present. Must be the
      same for all genomic intervals within a transcript.
    is_mitochondrial: whether the transcript is on the mitochondria chromosome.
    strand_int: strand on which transcript is present as an int. -1 for negative
      strand +1 for positive strand
    strand: strand (positive or negative) on which transcript is present. Must
      be the same for all genomic intervals within a transcript.
    is_negative_strand: a boolean value indicating whether transcript is on
      negative strand.
    is_positive_strand: a boolean value indicating whether transcript is on
      positive strand.
    transcript_interval: a genomic interval of a transcript.
    selenocysteines: a list of intervals where selenocysteines are present
      within a transcript.
    selenocysteine_pos_in_protein: a list of 0-based positions of
      selenocysteines in protein encoded by the transcript.
    is_coding: a value indicating whether a `Transcript` contains coding
      sequences (CDS) or not.
    cds_including_stop_codon: a list of CDS and stop_codon intervals with
      overlapping intervals merged.
    utr5: A list of genomic intervals representing 5' untranslated region. 5'
      UTR doesn't include start codon. There may be no 5' UTR present in the
      transcript or UTRs can be split by introns.
    utr3: A list of genomic intervals representing 3' untranslated region. 3'
      UTR doesn't include stop codon. There may be no 3' UTR present in the
      transcript or UTRs can be split by introns.
    splice_regions: a list of splice regions within a transcript.
    splice_donor_sites: a list of splice donor sites. Commonly, the RNA sequence
      that is removed ends with AG at its 3′ end.
    splice_acceptor_sites: a list of splice acceptor sites. Commonly, the RNA
      sequence that is removed begins with the dinucleotide GU at its 5′ end.
    splice_donors: a list of splice donors. The first nucleotide of the intron
      (0-based).
    splice_acceptors: a list of splice acceptors. The last nucleotide of the
      intron (0-based).
  """

  exons: list[genome.Interval]
  cds: list[genome.Interval] | None = None
  start_codon: list[genome.Interval] | None = None
  stop_codon: list[genome.Interval] | None = None
  transcript_id: str | None = dataclasses.field(compare=False, default=None)
  gene_id: str | None = dataclasses.field(compare=False, default=None)
  protein_id: str | None = dataclasses.field(compare=False, default=None)
  uniprot_id: str | None = dataclasses.field(compare=False, default=None)
  info: dict[str, Any] = dataclasses.field(
      default_factory=dict, repr=False, compare=False, hash=False
  )

  def offset_in_cds(self, genome_position: int) -> int | None:
    """Return the offset within the set of CDS exons of `genome_position`.

    Args:
      genome_position: A coordinate presumed to be on the same chromosome as
        this transcript.

    Returns:
      The offset of `genome_position` from the start of the CDS, accounting for
      strand, or None, if `genome_position` does not overlap the CDS.
    """
    offset = 0
    for cds_exon in self.cds_including_stop_codon[:: self.strand_int]:
      if cds_exon.start <= genome_position < cds_exon.end:
        if self.is_positive_strand:
          return offset + genome_position - cds_exon.start
        else:
          return offset + cds_exon.end - genome_position - 1
      offset += cds_exon.width
    return None

  @property
  def chromosome(self) -> str:
    """Gets the chromosome name on which the transcript is present.

    Returns:
      The chromosome name.
    """
    return self.exons[0].chromosome

  @property
  def is_mitochondrial(self) -> bool:
    """Gets whether the transcript is on the mitochondria chromosome.

    Returns:
      True if the transcript is on the mitochondria chromosome, False otherwise.
    """
    return self.chromosome in MITOCHONDRIAL_CHROMS

  # TODO: b/376466056 - Unify strand representations.
  @property
  def strand_int(self) -> int:
    """Gets the strand as an integer.

    Returns:
      -1 for negative strand, +1 for positive strand, 0 for unknown.
    """
    return {genome.STRAND_NEGATIVE: -1, genome.STRAND_POSITIVE: +1}.get(
        self.strand, 0
    )

  @property
  def strand(self) -> str:
    """Gets the strand on which the transcript is present.

    Returns:
      The strand (positive or negative).
    """
    return self.exons[0].strand

  @property
  def is_positive_strand(self) -> bool:
    return self.strand == genome.STRAND_POSITIVE

  @property
  def is_negative_strand(self) -> bool:
    return self.strand == genome.STRAND_NEGATIVE

  @functools.cached_property
  def transcript_interval(self) -> genome.Interval:
    """Gets a genomic interval of a transcript.

    Returns:
      A genomic interval of a transcript where transcript start is equal to the
      first exon start and transcript end is equal to the last exon end.
    """
    return genome.Interval(
        self.chromosome,
        self.exons[0].start,
        self.exons[-1].end,
        strand=self.strand,
    )

  @functools.cached_property
  def selenocysteines(self) -> list[genome.Interval]:
    if 'selenocysteines' not in self.info:
      return []
    return self.info['selenocysteines']

  @functools.cached_property
  def selenocysteine_pos_in_protein(self) -> list[int]:
    # 0-based
    selenocystein_pos = []
    for selenocysteine in self.selenocysteines:
      if selenocysteine.info['cds_offset'] is None:
        raise ValueError(
            'Transcript cannot be translated due to '
            'bad input data of selenocysteines.'
        )
      selenocystein_pos.append(selenocysteine.info['cds_offset'] // 3)
    return selenocystein_pos

  @functools.cached_property
  def introns(self) -> list[genome.Junction]:
    """Get a list of intron intervals.

    Returns:
      A list of genomic intervals representing introns, where a single intron
      junction is an interval spanning between two adjacent exons.
    """
    intron_intervals = []
    for i in range(1, len(self.exons)):
      intron_intervals.append(
          genome.Junction(
              self.chromosome,
              self.exons[i - 1].end,
              self.exons[i].start,
              strand=self.strand,
          )
      )
    return intron_intervals

  @functools.cached_property
  def is_coding(self) -> bool:
    return bool(self.cds)

  @functools.cached_property
  def cds_including_stop_codon(self) -> list[genome.Interval]:
    """Obtains coding sequences including stop codon.

    By default gtf files exclude stop codons from CDS while gff include stop
    codons within coding sequences.
    """
    if not self.is_coding:
      return []
    return genome.merge_overlapping_intervals(
        self.cds + (self.stop_codon or [])
    )

  @functools.cached_property
  def utr5(self) -> list[genome.Interval]:
    return self._get_utr(self.strand != genome.STRAND_NEGATIVE)

  @functools.cached_property
  def utr3(self) -> list[genome.Interval]:
    return self._get_utr(self.strand == genome.STRAND_NEGATIVE)

  def _get_utr(self, before: bool) -> list[genome.Interval]:
    """Gets the UTRs located before/after first/last coding sequence."""
    utrs = []
    if not self.cds:
      return utrs

    merged_cds_stop = genome.merge_overlapping_intervals(
        self.cds + (self.stop_codon or [])
    )

    if before:
      start, end = 0, merged_cds_stop[0].start
    else:
      start, end = merged_cds_stop[-1].end, sys.maxsize

    valid_interval = genome.Interval(
        self.chromosome, start, end, strand=self.strand
    )

    for exon in filter(lambda x: x.overlaps(valid_interval), self.exons):
      intersect = valid_interval.intersect(exon)
      if intersect:
        utrs.append(intersect)
    return utrs

  # TODO: b/376465275 - deal with cases where exon shorter than 3 bp length
  # TODO: b/376465275 - deal with cases where intron is shorther than 4 bp
  @functools.cached_property
  def splice_regions(self) -> list[genome.Interval]:
    """Obtains and returns splice regions of a transcript.

    splice region (SO:0001630) is "within 1-3 bases of the exon or 3-8 bases of
    the intron.
    """
    if not self.introns:
      return []
    splice_regions = []

    for intron, prev_exon, next_exon in zip(
        self.introns, self.exons[:-1], self.exons[1:]
    ):
      if prev_exon.width > 2:
        splice_regions.append(
            genome.Interval(
                self.chromosome,
                intron.start - 3,
                intron.start,
                strand=self.strand,
            )
        )

      if next_exon.width > 2:
        splice_regions.append(
            genome.Interval(
                self.chromosome, intron.end, intron.end + 3, strand=self.strand
            )
        )

      if intron.width > 4:
        splice_regions.append(
            genome.Interval(
                self.chromosome,
                intron.start + 2,
                min(intron.start + 8, intron.end - 2),
                strand=self.strand,
            )
        )
        splice_regions.append(
            genome.Interval(
                self.chromosome,
                max(intron.end - 8, intron.start + 2),
                intron.end - 2,
                strand=self.strand,
            )
        )
    return genome.merge_overlapping_intervals(splice_regions)

  @functools.cached_property
  def splice_donor_sites(self) -> list[genome.Interval]:
    return self._get_splice_sites(False, intron_overhang=2, exon_overhang=0)

  @functools.cached_property
  def splice_acceptor_sites(self) -> list[genome.Interval]:
    return self._get_splice_sites(True, intron_overhang=2, exon_overhang=0)

  @functools.cached_property
  def splice_donors(self) -> list[genome.Interval]:
    # To be consistent with the splice sites defined by intron start and end,
    # the overhang for donor and acceptor are different.
    return self._get_splice_sites(  # pytype: disable=bad-return-type  # enable-cached-property
        False, intron_overhang=1, exon_overhang=0
    )

  @functools.cached_property
  def splice_acceptors(self) -> list[genome.Interval]:
    return self._get_splice_sites(  # pytype: disable=bad-return-type  # enable-cached-property
        True, intron_overhang=0, exon_overhang=1
    )

  # TODO: b/376465275 - deal with cases where intron shorter than 4 bp length.
  def _get_splice_sites(
      self, acceptor: bool, intron_overhang: int, exon_overhang: int
  ) -> list[genome.Interval]:
    """Obtains splice acceptor/donor intervals.

    https://www.nature.com/scitable/topicpage/rna-splicing-introns-exons-and-spliceosome-12375/#:~:text=Introns%20are%20removed%20from%20primary,AG%20at%20its%203%E2%80%B2%20end
    Introns are removed from primary transcripts by cleavage at conserved
    sequences called splice sites. These sites are found at the 5′ and 3′
    ends of introns. Most commonly, the RNA sequence that is removed begins with
    the dinucleotide GU at its 5′ end, and ends with AG at its 3′ end.

    if - strand, the end two bases of intron are splice donor bases,
    if +, then the start two bases.

    Args:
      acceptor: value indicating whether splice acceptor or donor should be
        obtained.
      intron_overhang: bases into the intron.
      exon_overhang: bases into the exon.

    Returns:
      List of intervals of splice acceptor/donor sites.
    """
    if not self.introns:
      return []
    splice_sites = []
    for intron in self.introns:  # pylint:disable=not-an-iterable
      if intron.width < 4:
        continue
      if self.is_negative_strand != acceptor:
        splice = genome.Interval(
            self.chromosome,
            intron.end - intron_overhang,
            intron.end + exon_overhang,
            strand=self.strand,
        )
      else:
        splice = genome.Interval(
            self.chromosome,
            intron.start - exon_overhang,
            intron.start + intron_overhang,
            strand=self.strand,
        )
      splice_sites.append(splice)
    return splice_sites

  def __post_init__(self):
    if not self.exons:
      raise ValueError('Transcript must contain at least one exon.')

    for exon in self.exons:
      if exon.strand != self.strand or exon.chromosome != self.chromosome:
        raise ValueError(
            'Transcript intervals are inconsistent. All intervals of a '
            'transcript should have same strand and chromosome.'
        )

    if self.cds:
      # first exons can be part of UTR. Searching for the first coding exon.
      index = 0
      for exon in self.exons:
        # if overlaps, will check whether exon contains it in the latter loop.
        if exon.overlaps(self.cds[0]):
          break
        index += 1
      if index == len(self.exons) or len(self.cds) + index > len(self.exons):
        raise ValueError(
            'The number of coding exons must be the same as CDS '
            'and CDS cannot be outside of the exon intervals.'
        )

      # checks each subsequent exon is coding
      for seq, exon in zip(self.cds, self.exons[index : index + len(self.cds)]):
        if not exon.contains(seq):
          raise ValueError(
              'The number of coding exons must be the same as CDS '
              'and CDS cannot be outside of the exon intervals.'
          )
        if seq.strand != self.strand:
          raise ValueError(
              'Transcript intervals are inconsistent. All intervals of a '
              'transcript should have same strand and chromosome.'
          )

    for sc in self.selenocysteines:  # pylint:disable=not-an-iterable
      sc_pos = sc.end - 1 if sc.negative_strand else sc.start
      sc.info['cds_offset'] = self.offset_in_cds(sc_pos)

  def __len__(self):
    return self.transcript_interval.width

  @classmethod
  def from_gtf_df(
      cls,
      transcript_df: pd.DataFrame,
      ignore_info: bool = True,
      fix_truncation: bool = False,
  ) -> 'Transcript':
    """Initialises Trancript object from a given transcript dataframe.

    Args:
      transcript_df: Dataframe representing a transcript. The dataframe must
        contain a single transcript.
      ignore_info: If True, other columns in transcript_df won't be added to the
        info field, except transcript_type and selenocysteines.
      fix_truncation: Whether or not apply truncation fixation to CDS.

    Returns:
      Initialised Transcript object.
    Raises:
      ValueError: if the dataframe provided is invalid (no or more than one
      transcript, transcript has inconsistent strand or chromosome,
      transcript doesn't contain exons, CDS are not within exons, etc.)
    """
    if transcript_df.empty:
      raise ValueError('transcript_df is empty')
    if 'Feature' not in transcript_df:
      raise ValueError('transcript_df must contain Feature column.')

    if (
        'transcript_id' in transcript_df
        and len(transcript_df.transcript_id.unique()) > 1
    ):
      raise ValueError('transcript_df should only contain a single transcript.')

    # Convert rows to genome.Interval list.
    transcript_df = transcript_df.sort_values(by='Start')
    intervals_per_feature = collections.defaultdict(list)
    exon_row = None
    for _, row in transcript_df.iterrows():
      interval = genome.Interval.from_pyranges_dict(
          row, ignore_info=True
      )  # pytype: disable=wrong-arg-types  # pandas-drop-duplicates-overloads
      if row.Feature in ['CDS', 'stop_codon']:
        interval.info['frame'] = int(row.Frame)
      if exon_row is None and row.Feature == 'exon':
        exon_row = row
      intervals_per_feature[row.Feature].append(interval)

    if exon_row is None:
      raise ValueError('transcript_df must contain at least one exon')

    # Seed info.
    if ignore_info:
      info = {}
    else:
      skip = list(genome.PYRANGES_INTERVAL_COLUMNS) + [
          'Feature',
          'transcript_type',
          'Selenocysteines',
          'gene_type',
      ]
      info = {k: v for k, v in exon_row.items() if k not in skip}

    if 'transcript_type' in exon_row:
      info['transcript_type'] = exon_row['transcript_type']

    if 'Selenocysteine' in intervals_per_feature:
      info['selenocysteines'] = intervals_per_feature['Selenocysteine']

    if 'gene_type' in exon_row:
      info['gene_type'] = exon_row['gene_type']

    transcript_obj = cls(
        exons=intervals_per_feature['exon'],
        cds=intervals_per_feature.get('CDS', None),
        start_codon=intervals_per_feature.get('start_codon', None),
        stop_codon=intervals_per_feature.get('stop_codon', None),
        transcript_id=exon_row.get('transcript_id', None),
        gene_id=exon_row.get('gene_id', None),
        protein_id=exon_row.get('protein_id', None),
        uniprot_id=exon_row.get('uniprot_id', None),
        info=info,
    )
    if fix_truncation:
      return Transcript.fix_truncation(transcript_obj)
    return transcript_obj

  @classmethod
  def fix_truncation(cls, transcript: 'Transcript') -> 'Transcript':
    """Fixes CDS start and stop positions to be within coding frame.

    Args:
      transcript: a transcript to fix.

    Returns:
      New transcript with set start/stop codons and fixed CDS if the total
      length of CDS is > 6. Returns a copy of original transcript otherwise.
    """
    cds = sorted(
        (transcript.cds or []) + (transcript.stop_codon or []),
        key=lambda x: x.start,
    )
    cdna_len = sum(seq.width for seq in cds)
    if cdna_len < 7:
      return copy.deepcopy(transcript)

    positive_strand = transcript.is_positive_strand
    try:
      frame = cds[0 if positive_strand else -1].info['frame']
    except KeyError as key_error:
      raise KeyError(
          'CDS intervals are missing frame information,'
          ' truncations cannot be deduced.'
      ) from key_error
    frame_last = (cdna_len - frame) % 3

    cds, start_codon = cls._fix_coding_frame(
        five_prime=True,
        beginning=positive_strand,
        frame=frame,
        cds_transcript=cds,
    )

    cds, stop_codon = cls._fix_coding_frame(
        five_prime=False,
        beginning=not positive_strand,
        frame=frame_last,
        cds_transcript=cds,
    )

    return cls(
        exons=[exon.copy() for exon in transcript.exons],
        cds=cds,
        start_codon=start_codon,
        stop_codon=stop_codon,
        transcript_id=transcript.transcript_id,
        gene_id=transcript.gene_id,
        protein_id=transcript.protein_id,
        uniprot_id=transcript.uniprot_id,
        info={**transcript.info, 'truncation_fixed': True},
    )

  @classmethod
  def _fix_coding_frame(
      cls,
      five_prime: bool,
      beginning: bool,
      frame: int,
      cds_transcript: list[genome.Interval],
  ) -> tuple[list[genome.Interval], list[genome.Interval]]:
    """Fixes coding frame for a transcript and returns a new start/stop codon.

    Args:
      five_prime: a value indicating whether a 5' end to be fixed.
      beginning: indicates whether the beginning or the end of the transript to
        be fixed.
      frame: a current coding frame (0, 1, 2). For fixing 5' end, it is a
        position at which the first full codon starts within a cds. For fixing
        3' end, this indicates where the last full codon ends within a cds.
      cds_transcript: a list of coding sequence intervals.

    Returns:
      A pair of lists where the first item is a list of CDS with fixed coding
      frame and the second item is a start/stop codon.
    """

    def shorten_intervals(
        cds_transcript: list[genome.Interval],
        beginning: bool,
        frame: int,
    ) -> list[genome.Interval]:
      cds = [interval.copy() for interval in cds_transcript]
      index = 0 if beginning else -1
      while frame > 0:
        if cds[index].width > frame:
          if beginning:
            cds[index].start += frame
          else:
            cds[index].end -= frame
          frame = 0
        else:
          frame -= cds[index].width
          del cds[index]
      return cds

    # Fix CDS coding frame.
    fixed_cds = shorten_intervals(cds_transcript, beginning, frame)

    # Set codon.
    codon_bases = 3
    fixed_codon = []
    for seq in fixed_cds if beginning else fixed_cds[::-1]:
      if codon_bases == 0:
        break
      start, end = seq.start, seq.end
      if seq.width > codon_bases and beginning:
        end = seq.start + codon_bases
      elif seq.width > codon_bases:
        start = seq.end - codon_bases
      interval = genome.Interval(seq.chromosome, start, end, strand=seq.strand)
      fixed_codon.append(interval)
      codon_bases -= interval.width

    if five_prime:
      fixed_cds[0 if beginning else -1].info['frame'] = 0
    else:
      # Remove newly set stop interval from cds.
      fixed_cds = shorten_intervals(fixed_cds, beginning, 3)
    return fixed_cds, fixed_codon


class _RangeExtractor:
  """Range extractor from gtf df."""

  def __init__(self, df: pd.DataFrame):
    self._df_start_end = {
        chromosome: (dfc, dfc['Start'].values, dfc['End'].values)
        for chromosome, dfc in df.groupby('Chromosome')
    }
    self._df_empty = df.iloc[:0]

  def extract(self, interval: genome.Interval) -> pd.DataFrame:
    """Finds all rows that contain the input Interval.

    Args:
      interval: query Interval

    Returns:
    a dataframe containing genome intervals that contain the query Interval
    """
    if interval.chromosome not in self._df_start_end:
      return self._df_empty
    else:
      dfc, start, end = self._df_start_end[interval.chromosome]
      start_contained = (interval.start <= start) & (start <= interval.end)
      end_contained = (interval.start <= end) & (end <= interval.end)
      interval_contained = (interval.start >= start) & (end >= interval.end)
      return dfc[start_contained | end_contained | interval_contained]


class TranscriptExtractor:
  """Transcript extractor from gtf."""

  def __init__(self, gtf_df: pd.DataFrame) -> None:
    """Init.

    Args:
      gtf_df: pd.DataFrame of GENCODE GTF entries containing transcript
        annotation. Must contain columns 'Chromosome', 'Start', 'End', 'Strand',
        'Feature', and 'transcript_id'.
    """
    self._transcript_extractor = _RangeExtractor(
        gtf_df[gtf_df.Feature == 'transcript'][
            ['Chromosome', 'Start', 'End', 'Strand', 'transcript_id']
        ]
    )
    self._transcript_indexed_gtf = gtf_df.set_index('transcript_id')
    self._transcript_from_id_cache = None

  def cache_transcripts(self) -> None:
    """Speed up extract() by converting GTF to dictionary of Transcripts.

    This may take ca 11 minutes on the full human genome GTF of 84k protein
    coding transcripts and 15 s on chr22 (1.5k transcripts).

    Running cache_transcripts() will speed up .extract() by ca 5-10x:
    (11 ms vs 65 ms tested on chr22, or 15 ms vs 160 ms on whole genome).
    """
    self._transcript_from_id_cache = self._transcripts_from_gtf(
        self._transcript_indexed_gtf.reset_index()
    )

  def _transcripts_from_gtf(
      self,
      gtf_df: pd.DataFrame,
  ) -> dict[str, Transcript]:
    return (
        {  # pytype: disable=bad-return-type  # pandas-drop-duplicates-overloads
            transcript_id: (
                Transcript.fix_truncation(
                    Transcript.from_gtf_df(gtf_subset, ignore_info=False)
                )
            )
            for transcript_id, gtf_subset in gtf_df.groupby('transcript_id')
        }
    )

  def extract(self, interval: genome.Interval) -> list[Transcript]:
    """Extract transcripts overlapping an interval.

    Args:
      interval: Interval used to overlap with transcripts.

    Returns:
      List of transcript overlapping `interval`.
    """
    gtf_df_within_interval = self._transcript_extractor.extract(interval)
    if gtf_df_within_interval.empty:
      return []

    transcript_ids = gtf_df_within_interval.transcript_id.dropna().unique()
    if self._transcript_from_id_cache is not None:
      return [
          self._transcript_from_id_cache[transcript_id]
          for transcript_id in transcript_ids
      ]
    else:
      transcript_gtfs = self._transcript_indexed_gtf.loc[
          transcript_ids
      ].reset_index()
      return list(self._transcripts_from_gtf(transcript_gtfs).values())
