import json
import re
import uuid

from semver.version import Version

from django.db import models
from django.utils import timezone
from django.db.models.functions import Concat
from django.db.models import Value

from issues.models import Issue, TurningPoint, TurningPointKind


RE_PACKAGE_VERSION = re.compile('((?P<package>.*)[@])?(?P<version>.*)')


def is_valid_semver(full_version):
    try:
        version = RE_PACKAGE_VERSION.match(full_version).groupdict()["version"]
        Version.parse(version)
        return True
    except ValueError:
        return False


def release_sort_key(release):
    return (
        release.sort_epoch,
        Version.parse(release.semver) if release.is_semver else release.date_released
    )


def ordered_releases(*filter_args, **filter_kwargs):
    """Sorting Release objects in code (as opposed to in-DB) to facilitate semver-based sorting when applicable"""
    releases = Release.objects.filter(*filter_args, **filter_kwargs)

    return sorted(releases, key=release_sort_key)


class Release(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

    # sentry does releases per-org; we don't follow that example. our belief is basically: [1] in reality releases are
    # per software package and a software package is basically a bugsink project and [2] any cross-project-per-org
    # analysis you might do is more likely to be in the realm of "transactions", something we don't want to support.
    project = models.ForeignKey("projects.Project", blank=False, null=False, on_delete=models.DO_NOTHING)

    # full version as provided by either implicit (per-event) or explicit (some API) means, including package name
    # max_length matches Even.release (which is deduced from Sentry)
    version = models.CharField(max_length=250, null=False, blank=False)

    date_released = models.DateTimeField(default=timezone.now)

    semver = models.CharField(max_length=255, null=False, editable=False)
    is_semver = models.BooleanField(editable=False)

    # sort_epoch is a way to ensure that we can sort releases alternatingly by date and by semver. The idea is that
    # whenever we switch from one to the other, we increment the epoch. This way, we can sort releases by epoch first
    # and then by date or semver. i.e. when transitioning between version schemes, ordering will "Just Work".
    # The typical scenario involves a straightforward shift from a state of "being too lazy to set up and merely using
    # hashes" to adopting semver.
    sort_epoch = models.IntegerField(editable=False)

    def save(self, *args, **kwargs):
        if self.is_semver is None:
            self.is_semver = is_valid_semver(self.version)
            if self.is_semver:
                self.semver = RE_PACKAGE_VERSION.match(self.version)["version"]

            # whether doing this epoch setting inline on-creation is a smart idea... will become clear soon enough.
            any_release_from_last_epoch = Release.objects.filter(project=self.project).order_by("sort_epoch").last()
            if any_release_from_last_epoch is None:
                self.sort_epoch = 0
            elif self.is_semver == any_release_from_last_epoch.is_semver:
                self.sort_epoch = any_release_from_last_epoch.sort_epoch
            else:
                self.sort_epoch = any_release_from_last_epoch.sort_epoch + 1

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

    class Meta:
        unique_together = ("project", "version")

        indexes = [
            models.Index(fields=["sort_epoch"]),
        ]

    def get_short_version(self):
        if self.version == "":
            # the reason for this little hack is to have something show up in the UI for this case. I 'assume' (mother
            # of all ...) that in most reasonable cases we actually don't show releases if there's an empty release
            # (i.e. for the single empty release we really shouldn't, because then project.has_releases should be false)
            # but I've seen at least one case where you still have to show something even for the empty release: when
            # switching back to "no release". (that's why I say "most reasonable cases"). (observed in testing, because
            # test-events generally wildly vary in the release info they carry).
            return "«no version»"
        if self.is_semver:
            return self.version
        return self.version[:12]


def create_release_if_needed(project, version, event, issue=None):
    if version is None:
        # because `create_release_if_needed` is called with Issue.release (non-nullable), the below "won't happen"
        raise ValueError('The None-like version must be the empty string')

    # NOTE: we even create a Release for the empty release here; we need the associated info (date_released) if a
    # real release is ever created later.

    version = sanitize_version(version)

    release, release_created = Release.objects.get_or_create(project=project, version=version)
    if release_created and version != "":
        if not project.has_releases:
            project.has_releases = True
            project.save()

        if release == project.get_latest_release():
            resolved_by_next_qs = Issue.objects.filter(project=project, is_resolved_by_next_release=True)

            # NOTE: once we introduce an explicit way of creating releases (not event-based) we can not rely on a
            # triggering event anymore for our timestamp.

            TurningPoint.objects.bulk_create([TurningPoint(
                    project=project,
                    issue=issue, kind=TurningPointKind.NEXT_MATERIALIZED, triggering_event=event,
                    metadata=json.dumps({"actual_release": release.version}), timestamp=event.ingested_at)
                for issue in resolved_by_next_qs
            ])
            event.never_evict = True  # .save() will be called by the caller of this function

            resolved_by_next_qs.update(
                fixed_at=Concat("fixed_at", Value(release.version + "\n")),
                is_resolved_by_next_release=False,
                )

            if issue is not None and issue.is_resolved_by_next_release:
                # a bit of a hack: if we have an in-memory issue, we must update it as well.
                issue.fixed_at = issue.fixed_at + release.version + "\n"
                issue.is_resolved_by_next_release = False

    return release


def sanitize_version(version):
    """
    Implements the folllowing restrictions are from the Sentry documentation:

    > There are a few restrictions -- the release name cannot:
    >
    > - contain newlines, tabulator characters, forward slashes(/), or back slashes(\\)
    > - be (in their entirety) period (.), double period (..), or space ( )
    > - exceed 200 characters

    It does so as sanitize-dont-raise, i.e. it will return a sanitized version of the input string, but will not raise
    an exception if the input string is invalid. Reason: we care about having valid data (and we rely e.g. on the lack
    of newlines for our parsing), but we never want an invalid input to lead to discarded events. And there's no one
    to 'read' a rejected event and fix it.
    """

    step_1 = version.replace("\n", "").replace("\t", "").replace("/", "").replace("\\", "")
    step_2 = "sanitized" if step_1 in (".", "..", " ") else step_1
    step_3 = step_2[:200]
    return step_3
