"""
_____________________________________________________________________.

:PROJECT: LARAsuite

*lara_django_sequences models *

:details: lara_django_sequences database models.
         -
:authors: mark doerr <mark.doerr@uni-greifswald.de>

.. note:: -
.. todo:: - remove unwanted models/fields
________________________________________________________________________
"""

from __future__ import annotations

import hashlib
import json
import uuid
from logging import getLogger
from typing import Any

from django.conf import settings
from django.db import models
from django.utils import timezone
from lara_django_base.models import BlockchainHashesAbstr, ExtraDataAbstr, ItemStatus, MediaType, Namespace, Tag
from lara_django_people.models import ItemSubsetAbstr

settings.FIXTURES += ["1_sequence_class_fix"]

logger = getLogger(__name__)


class BlockchainHashes(BlockchainHashesAbstr):
    """
    Store hashes of data for blockchain verification.

    This class can be used to store hashes of data, which are stored in a blockchain.
    The hashes can be used to verify the integrity of the data.
    The hashes are stored in a separate table, to avoid circular dependencies.
    """

    model_curi = "lara:data/BlockchainHashes"


class ExtraData(ExtraDataAbstr):
    """
    Extend Sequence data with extra information.

    This class can be used to extend the Sequence data, by extra information,
    e.g., properties relevant only for certain sequences.
    """

    model_curi = "lara:sequences/ExtraData"
    image = models.ImageField(
        upload_to="sequences/images",
        blank=True,
        null=True,
        help_text="extra sequence images",
    )
    file = models.FileField(upload_to="sequences/", blank=True, null=True, help_text="rel. path/filename")

    def save(self, *args: Any, **kwargs: Any) -> None:
        """Generate default values for name_full."""
        if self.version is None or self.version == "":
            self.version = "0.0.1"

        if self.hash_sha256 is None:
            if self.data_json is not None:
                to_hash = json.dumps(self.data_json)  # +  str(self.UUID) +
                self.hash_sha256 = hashlib.sha256(to_hash.encode("utf-8")).hexdigest()
            else:
                self.hash_sha256 = hashlib.sha256(str(self.extradata_id).encode("utf-8")).hexdigest()

        if self.name is None or self.name == "":
            if self.name_display is None or self.name_display == "":
                self.name = "_".join(
                    (
                        timezone.now().strftime("%Y%m%d_%H%M%S"),
                        "data",
                        self.hash_sha256[:8],
                    )
                )
            else:
                self.name = self.name_display.replace(" ", "_").lower().strip()

        else:
            self.name = self.name.replace(" ", "_").lower().strip()
        if self.name_full is None or self.name_full == "":
            if self.namespace is not None and self.version is not None:
                self.name_full = f"{self.namespace.uri}/" + "_".join((self.name.replace(" ", "_"), self.version))
            else:
                self.name_full = "_".join((self.name.replace(" ", "_"), self.version))

        if not self.iri:
            netloc = self.namespace.parse_uri().netloc
            path = self.namespace.parse_uri().path

            formatted_name = self.name.replace(" ", "_").lower().strip()
            self.iri = f"{settings.LARA_PREFIXES['lara']}sequences/ExtraData/{netloc}{path}/{formatted_name}"

        if self.datetime_created is None:
            self.datetime_created = timezone.now()

        if self.datetime_last_modified is None:
            self.datetime_last_modified = timezone.now()

        super().save(*args, **kwargs)


class SequenceClass(models.Model):
    """classes  of sequences, like  DNA, RNA, PNA, XNA, peptide/protein ..."""

    model_curi = "lara:sequences/SequenceClass"
    sequenceclass_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    name = models.TextField(
        unique=True,
        help_text="classes  of sequences, like  DNA, RNA, PNA, XNA, peptide/protein ..",
    )
    # encoding should contain the number of bits and the mapping of the bits to the sequence
    # like: num_bits: 2 ; mapping : ["A", "C", "G", "T"] or ["A", "C", "G", "T", "U"]
    # for proteins it could be:
    # ["A", "C", "D", "E", "F", "G", "H", "I", "K", "L", "M", "N", "P", "Q", "R", "S", "T", "V", "W", "Y"]
    # the position in the list is the binary representation of the sequence
    encoding = models.JSONField(
        blank=True,
        null=True,
        help_text="encoding of the binary sequence, e.g. 4 bit, bit1: A, bit2: C, bit3: G, bit4: T",
    )
    iri = models.URLField(
        blank=True,
        null=True,
        unique=True,
        max_length=512,
        help_text="International Resource Identifier - IRI: is used for semantic representation ",
    )
    description = models.TextField(blank=True, null=True, help_text="description of the sequence class")

    def save(self, *args: Any, **kwargs: Any) -> None:
        """Generate default values for name."""
        if self.iri is None or self.iri == "":
            self.iri = (
                f"{settings.LARA_PREFIXES['lara']}sequences/SequenceClass/{self.name.replace(' ', '_').lower().strip()}"
            )

        super().save(*args, **kwargs)

    def __str__(self) -> str:
        """Return string representation of the sequence class."""
        return self.name or ""

    class Meta:
        """Meta configuration for SequenceClass model."""

        verbose_name_plural = "SequenceClasses"


class Sequence(models.Model):
    """Sequence class describing, sequences of DNA, RNA, PNA, XNA, peptide/protein ..."""

    model_curi = "lara:sequences/Sequence"
    sequence_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    namespace = models.ForeignKey(
        Namespace,
        related_name="%(app_label)s_%(class)s_namespaces_related",
        related_query_name="%(app_label)s_%(class)s_namespaces_related_query",
        on_delete=models.CASCADE,
        null=True,
        blank=True,
        help_text="namespace of sequence",
    )
    name_display = models.TextField(
        blank=True,
        null=True,
        help_text="human readable title of sequence, can be different from name",
    )
    name = models.TextField(help_text="short sequence name for reference")
    hash_sha256 = models.CharField(
        max_length=256,
        blank=True,
        null=True,
        help_text="SHA256 hash of item for identity/duplicate checking",
    )
    hash_blockchain = models.CharField(
        max_length=512,
        blank=True,
        null=True,
        help_text="hash of sequence item for blockchain",
    )
    sequence_class = models.ForeignKey(
        SequenceClass,
        related_name="%(app_label)s_%(class)s_sequence_class_related",
        related_query_name="%(app_label)s_%(class)s_sequence_class",
        on_delete=models.CASCADE,
        blank=True,
        help_text="sequence class, like  DNA, RNA, PNA, XNA, peptide/protein ... ",
    )
    sequence = models.TextField(blank=True, null=True, help_text="sequence data as text")
    # sequence = models.BinaryField(
    #     blank=True,
    #     help_text="sequence can be any supported format, defined by substance "
    #               "(polymer) file type, binary, to save space",
    # )
    properties = models.JSONField(
        blank=True,
        null=True,
        help_text="properties of the sequence, e.g., length, GC content, UniProt Accession, NCBI Accession, PDB ID ...",
    )
    uniprot_accession = models.TextField(blank=True, null=True, help_text="UniProt Accession of the sequence")
    ncbi_gi = models.TextField(blank=True, null=True, help_text="NCBI GenInfo Identifier (GI)")
    ncbi_accession_version = models.TextField(
        blank=True, null=True, help_text="NCBI Accession Number.version identifier."
    )
    source_organism = models.JSONField(
        blank=True, null=True, help_text="organism from which the sequence originated - important for codon usage"
    )
    media_type = models.ForeignKey(
        MediaType,
        related_name="%(app_label)s_%(class)s_media_type_related",
        on_delete=models.CASCADE,
        null=True,
        blank=True,
        help_text="file type of sequence data (file)",
    )
    sequence_file = models.FileField(upload_to="sequences/", blank=True, null=True, help_text="rel. path/filename")

    status = models.ForeignKey(
        ItemStatus,
        related_name="%(app_label)s_%(class)s_status_related",
        on_delete=models.CASCADE,
        null=True,
        blank=True,
        help_text="status of sequence, e.g., selected, processed, synchronised ... ",
    )
    version = models.TextField(blank=True, null=True, help_text="version of the sequence")
    tags = models.ManyToManyField(
        Tag,
        blank=True,
        related_name="%(app_label)s_%(class)s_tags_related",
        related_query_name="%(app_label)s_%(class)s_tags",
        help_text="tags for sequence",
    )

    description = models.TextField(blank=True, null=True, help_text="description of the sequence")
    iri = models.URLField(
        blank=True,
        null=True,
        unique=True,
        max_length=512,
        help_text="International Resource Identifier - IRI: is used for semantic representation ",
    )
    datetime_last_modified = models.DateTimeField(
        blank=True, null=True, help_text="date and time when data was last modified"
    )

    class Meta:
        """Meta configuration for Sequence model."""

        # ~ verbose_name = 'ForumContribution'
        # ~ verbose_name_plural = 'ForumContributions'
        # db_table = "lara_metainfo_item_class"
        constraints = (
            models.UniqueConstraint(
                fields=["namespace", "name", "version"],
                name="%(app_label)s_%(class)s_name_namespace_version_unique",
            ),
        )

    def __str__(self) -> str:
        """Return string representation of the sequence."""
        return self.name or ""

    def __repr__(self) -> str:
        """Return string representation of the sequence."""
        return self.name or ""

    @property
    def encoding(self) -> dict[str, Any] | None:
        """Get the encoding from the sequence class."""
        if not hasattr(self, "_cached_encoding"):
            if self.sequence_class is None:
                logger.error("Sequence class with encoding is not defined")
                return None
            self._cached_encoding = self.sequence_class.encoding
        return self._cached_encoding

    @property
    def sequence_text(self) -> str | None:
        """Get the sequence as decoded text."""
        return self._decode_sequence(self.sequence)

    @sequence_text.setter
    def sequence_text(self, sequence_text: str) -> None:
        self._encode_sequence(sequence_text)

    def _encode_sequence(self, sequence_text: str) -> None:
        encoding = self.encoding
        if encoding is None:
            return

        num_bits = encoding["num_bits"]
        mapping_list = encoding["mapping"]
        mapping_dict = {mapping_list[i]: i for i in range(len(mapping_list))}
        sequence_binary = b""

        for char in sequence_text:
            sequence_binary += mapping_dict[char].to_bytes(num_bits, byteorder="big")

        self.sequence = sequence_binary

    def _encode_sequence_old(self, sequence_text: str) -> None:
        # used sequence_class.encoding to encode sequence

        if self.sequence_class is None:
            logger.error("Sequence class with encoding is not defined")
            return
        encoding = self.sequence_class.encoding
        # number of bits
        num_bits = encoding["num_bits"]
        # mapping of bits to sequence
        mapping_list = encoding["mapping"]

        # create a dictionary with the mapping
        mapping_dict = {mapping_list[i]: i for i in range(len(mapping_list))}

        # use the mapping to encode the sequence, each character in the sequence_text is mapped to num_bits bits
        sequence_binary = b""

        for char in sequence_text:
            sequence_binary += mapping_dict[char].to_bytes(num_bits, byteorder="big")

        self.sequence = sequence_binary

    def _decode_sequence(self, sequence_binary: bytes | None) -> str | None:
        encoding = self.encoding
        if encoding is None:
            return None

        num_bits = encoding["num_bits"]
        mapping_list = encoding["mapping"]
        mapping_dict = {i: mapping_list[i] for i in range(len(mapping_list))}
        sequence_text = ""

        for i in range(0, len(sequence_binary), num_bits):
            sequence_text += mapping_dict[int.from_bytes(sequence_binary[i : i + num_bits], byteorder="big")]

        return sequence_text

    def _decode_sequence_old(self, sequence_binary: bytes | None) -> str | None:
        # used sequence_class.encoding to encode sequence
        if self.sequence_class is None:
            logger.error("Sequence class with encoding is not defined")
            return None
        encoding = self.sequence_class.encoding
        # number of bits
        num_bits = encoding["num_bits"]
        # mapping of bits to sequence
        mapping_list = encoding["mapping"]

        # create a dictionary with the mapping
        mapping_dict = {i: mapping_list[i] for i in range(len(mapping_list))}

        # use the mapping to decode the sequence, each num_bits bits are mapped to a character in the sequence_text

        sequence_text = ""

        for i in range(0, len(sequence_binary), num_bits):
            sequence_text += mapping_dict[int.from_bytes(sequence_binary[i : i + num_bits], byteorder="big")]

        return sequence_text

    def save(self, *args: Any, **kwargs: Any) -> None:
        """Generate default values for IRIs and names."""
        if self.version is None or self.version == "":
            self.version = "0.0.1"

        if self.hash_sha256 is None:
            if self.sequence is not None:
                to_hash = json.dumps(self.sequence) + str(self.sequence_id)
                self.hash_sha256 = hashlib.sha256(to_hash.encode("utf-8")).hexdigest()
            else:
                self.hash_sha256 = hashlib.sha256(str(self.sequence_id).encode("utf-8")).hexdigest()

        if self.name is None or self.name == "":
            if self.name_display is None or self.name_display == "":
                self.name = "_".join(
                    (
                        timezone.now().strftime("%Y%m%d_%H%M%S"),
                        "sequence",
                        self.hash_sha256[:8],
                    )
                )
            else:
                self.name = self.name_display.replace(" ", "_").lower().strip()

        else:
            self.name = self.name.replace(" ", "_").lower().strip()

        if not self.iri:
            netloc = self.namespace.parse_uri().netloc
            path = self.namespace.parse_uri().path

            formatted_name = self.name.replace(" ", "_").lower().strip()
            self.iri = f"{settings.LARA_PREFIXES['lara']}sequences/Sequence/{netloc}{path}/{formatted_name}"

        if self.datetime_last_modified is None:
            self.datetime_last_modified = timezone.now()

        super().save(*args, **kwargs)


class SequencesSubset(ItemSubsetAbstr):
    """
    Store a subset of sequences for analysis or projects.

    This class can be used to store a subset of sequences, e.g., a selection of sequences,
    which are relevant for a certain analysis, project, publication.
    """

    model_curi = "lara:sequences/SequencesSubset"

    sequencessubset_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

    sequences = models.ManyToManyField(
        Sequence,
        related_name="%(app_label)s_%(class)s_sequences_related",
        related_query_name="%(app_label)s_%(class)s_sequences",
        help_text="sequences in the subset",
        through="OrderedSequencesSubset",
    )


class OrderedSequencesSubset(models.Model):
    """
    Through model for the many-to-many relationship between SequencesSubset and Sequence.

    This class can be used to store the order of sequences in a subset.
    """

    model_curi = "lara:sequences/OrderedSequencesSubset"

    orderedsequencessubset_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

    sequence = models.ForeignKey(
        Sequence,
        related_name="%(app_label)s_%(class)s_sequence_related",
        related_query_name="%(app_label)s_%(class)s_sequence",
        on_delete=models.CASCADE,
        help_text="sequence in the subset",
    )

    sequences_subset = models.ForeignKey(
        SequencesSubset,
        related_name="%(app_label)s_%(class)s_sequencessubset_related",
        related_query_name="%(app_label)s_%(class)s_sequencessubset",
        on_delete=models.CASCADE,
        help_text="subset of sequences",
    )

    order = models.IntegerField(help_text="order of sequence in subset")

    class Meta:
        """Meta configuration for OrderedSequencesSubset model."""

        constraints = (
            models.UniqueConstraint(
                fields=["sequence", "sequences_subset"],
                name="%(app_label)s_%(class)s_sequence_sequencessubset_unique",
            ),
        )
        ordering = ("order",)

    def save(self, *args: Any, **kwargs: Any) -> None:
        """Generate default values for order."""
        if self.order is None:
            self.order = 0

        super().save(*args, **kwargs)
