"""LXC snapshot related models.

These are used by Mesos builds and Jenkins-LXC builds.

See also:

* changes-client actually creates, manages and uploads actual snapshot
  image files (which are just file system tarballs). Here we just keep track
  of snapshot IDs and bind them to projects/plans.
"""

from __future__ import absolute_import

from datetime import datetime
from uuid import uuid4
from enum import Enum

from sqlalchemy import Column, DateTime, ForeignKey
from sqlalchemy.orm import backref, relationship
from sqlalchemy.schema import UniqueConstraint

from changes.config import db
from changes.db.types.enum import Enum as EnumType
from changes.db.types.guid import GUID


class SnapshotStatus(Enum):
    """Used to track whether a snapshot or an image is ready to be used.

    State transitions that should be allowed:

      unknown -> anything
      pending -> failed, active
      active -> invalidated

    'failed' and 'invalidated' are terminal states.
    """
    # Unknown status (default, but should not normally be used as a status)
    unknown = 0
    # Snapshot/image ready to be used. Can only become active from
    # pending, and can only becoming invalidated from active.
    active = 1
    # Snapshot build failed. This is a terminal state that is reached
    # if the snapshot was never marked active.
    failed = 2
    # This implies that the snapshot (or image) was once marked as active
    # but was later marked as bad for some reason. This is the only state
    # that an active snapshot/image should ever be switched to.
    #
    # This is a terminal state that is reached for snapshots that were
    # at one point activate
    invalidated = 3
    # Waiting for snapshot build to finish
    pending = 4

    def __str__(self):
        return STATUS_LABELS[self]


STATUS_LABELS = {
    SnapshotStatus.unknown: 'Unknown',
    SnapshotStatus.pending: 'Pending',
    SnapshotStatus.active: 'Active',
    SnapshotStatus.failed: 'Failed',
    SnapshotStatus.invalidated: 'Invalidated',
}


class Snapshot(db.Model):
    """
    A snapshot is a set of LXC container images (up to one for each plan in a project).

    Each project can have an arbitrary number of snapshots, but only up
    to one "current snapshot" is actually used by builds (stored as
    ProjectOption) at any time.

    Snapshots are used in the Mesos and Jenkins-LXC environments.
    Snapshots are currently only used with changes-client.

    When running a build, the images of the current snapshot are used
    for individual jobs that are part of a build.  A snapshot image
    can be shared between multiple plans by setting snapshot_plan_id
    of a Plan.  By default, there is a separate image for each plan
    of a build.

    The status of a snapshot indicates whether it *can* be used for
    builds; it doesn't indicate whether the snapshot is actually used
    for builds right now. A snapshot is active if and only if all the
    corresponding snapshot images are active.

    A snapshot is generated by a slightly special snapshot build that
    uploads a snapshot at the end of the build.
    """

    __tablename__ = 'snapshot'

    id = Column(GUID, primary_key=True, default=uuid4)
    project_id = Column(
        GUID, ForeignKey('project.id', ondelete="CASCADE"), nullable=False)
    build_id = Column(GUID, ForeignKey('build.id'), unique=True)
    source_id = Column(GUID, ForeignKey('source.id'))
    # Most importantly, this tells if this snapshot can be used for builds (i.e., are all
    # component snapshot images active)?
    status = Column(EnumType(SnapshotStatus),
                    default=SnapshotStatus.unknown,
                    nullable=False, server_default='0')
    date_created = Column(DateTime, default=datetime.utcnow, nullable=False)

    # The build that generated this snapshot.
    build = relationship('Build', backref=backref('snapshot', uselist=False))
    project = relationship('Project', innerjoin=True)

    # The source that was used to generate the snapshot.
    source = relationship('Source')

    def __init__(self, **kwargs):
        super(Snapshot, self).__init__(**kwargs)
        if self.id is None:
            self.id = uuid4()

    @classmethod
    def get_current(cls, project_id):
        """Return the current Snapshot for a project (or None if one is not set)."""
        from changes.models.project import ProjectOption

        current_id = db.session.query(ProjectOption.value).filter(
            ProjectOption.project_id == project_id,
            ProjectOption.name == 'snapshot.current',
        ).scalar()
        if not current_id:
            return

        return Snapshot.query.get(current_id)


class SnapshotImage(db.Model):
    """
    Represents an individual image within a snapshot.

    An image is bound to a (snapshot, plan) and represents the low level base
    image that a snapshottable-job should be based on.

    Note that a project with multiple plans may have multiple, independent
    images per snapshot, as images aren't always shared between plans.
    """

    __tablename__ = 'snapshot_image'
    __table_args__ = (
        UniqueConstraint('snapshot_id', 'plan_id', name='unq_snapshotimage_plan'),
    )

    # The snapshot image id is used by changes-client to store and retrieve snapshots.
    # New ids are created by changes and passed on to changes-client.
    id = Column(GUID, primary_key=True, default=uuid4)
    snapshot_id = Column(
        GUID, ForeignKey('snapshot.id', ondelete="CASCADE"), nullable=False)
    plan_id = Column(
        GUID, ForeignKey('plan.id', ondelete="CASCADE"), nullable=False)
    job_id = Column(GUID, ForeignKey('job.id', ondelete="CASCADE"), unique=True)
    status = Column(EnumType(SnapshotStatus),
                    default=SnapshotStatus.unknown,
                    nullable=False, server_default='0')
    date_created = Column(DateTime, default=datetime.utcnow, nullable=False)

    snapshot = relationship('Snapshot', backref=backref('images',
                                                        order_by='SnapshotImage.date_created'))
    plan = relationship('Plan')
    # The job that was used to create this snapshot.
    job = relationship('Job')

    def __init__(self, **kwargs):
        super(SnapshotImage, self).__init__(**kwargs)
        if self.id is None:
            self.id = uuid4()

    def change_status(self, status):
        """
        The status field of snapshot is a redundant field that has to
        be maintained. Its essentially a cached aggregate over
        snapshot_image.status. This means that whenever we update
        snapshot_image.status we have to update snapshot.status.

        This method updates the current status to the new status given
        as a parameter.

        TODO we should probably verify that if the current status is
        active and we are tring to move it to a new status that is
        not "invalidated" we should give some error.

        XXX(jhance)
        Is this a sign of a defective schema? Computing snapshot.status
        should be possible in-query although I'm not sure to do it from
        within sqlalchemy.
        """
        self.status = status

        db.session.add(self)

        # We need to update the current database with the status of the
        # new image, but we don't commit completely until we have found
        # the status of the overall status and update it atomically
        db.session.flush()

        inactive_image_query = SnapshotImage.query.filter(
            SnapshotImage.status != SnapshotStatus.active,
            SnapshotImage.snapshot_id == self.snapshot.id,
        ).exists()
        if not db.session.query(inactive_image_query).scalar():
            # If the snapshot status isn't pending for whatever reason, then we
            # refuse to update its status to active because clearly some other
            # error occurred elsewhere in the pipeline (for example, the
            # snapshot build itself failing)
            if self.snapshot.status == SnapshotStatus.pending:
                self.snapshot.status = SnapshotStatus.active
        elif self.snapshot.status == SnapshotStatus.active:
            self.snapshot.status = SnapshotStatus.invalidated

        db.session.commit()

    @classmethod
    def get(cls, plan, snapshot_id):
        """Return the SnapshotImage for a plan or None if one is not set.
        """

        # This plan might be configured to be dependent on another plan's snapshot
        snapshot_plan = plan
        if plan.snapshot_plan is not None:
            snapshot_plan = plan.snapshot_plan

        snapshot = Snapshot.query.filter(Snapshot.id == snapshot_id).scalar()
        if snapshot is not None:
            return SnapshotImage.query.filter(
                        SnapshotImage.snapshot_id == snapshot.id,
                        SnapshotImage.plan_id == snapshot_plan.id,
                   ).scalar()
        return None
