import os
import json
import hashlib
import datetime
from decimal import Decimal

from django.conf import settings
from django.db import models
from django.db.models import F
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes import generic
from django.utils.timezone import now
from django.dispatch import receiver
from django.core.urlresolvers import reverse
from ua_parser import user_agent_parser

from boto.mturk.question import ExternalQuestion
from boto.mturk.connection import MTurkRequestError

from common.signals import marked_invalid
from common.utils import import_module
from common.models import EmptyModelBase
from accounts.models import UserProfile
from mturk.utils import get_mturk_connection, \
    get_or_create_mturk_worker, extract_mturk_attr, \
    qualification_to_boto, qualification_dict_to_boto
from mturk.signals import hit_expired


class MtModelBase(EmptyModelBase):
    added = models.DateTimeField(default=now)
    updated = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True


class MtSubmittedContent(EmptyModelBase):
    """ Wrapper around an object submitted for a HIT assignment """

    #: the HIT Assignment that contains this object
    assignment = models.ForeignKey(
        'mturk.MtAssignment', related_name='submitted_contents')

    #: generic relation to the submitted object (e.g. SubmittedShape)
    content_type = models.ForeignKey(ContentType)
    object_id = models.PositiveIntegerField()
    content = generic.GenericForeignKey('content_type', 'object_id')


class ExperimentSettings(MtModelBase):
    """ Settings for creating new HITs (existing HITs do not use this data).  """

    #: if true, automatically instantiate HITs for this experiment when content
    #: is available and instances of out_content_attr are not filled up
    auto_add_hits = models.BooleanField(default=False)

    #: reward per HIT
    reward = models.DecimalField(decimal_places=4, max_digits=8)

    #: number of output instances expected
    num_outputs_max = models.IntegerField(default=5)

    #: mininum number of similar results before the mode result
    #: is considered to be reliable
    min_output_consensus = models.IntegerField(default=4)

    #: number of content_type objects per hit
    contents_per_hit = models.IntegerField(default=1)

    #: if None, no feedback requested
    feedback_bonus = models.DecimalField(
        decimal_places=4, max_digits=8, null=True, blank=True)

    #: type of instance sent to worker
    content_type = models.ForeignKey(
        ContentType, related_name="experiment_settings_in")

    #: type of content generated by this task
    out_content_type = models.ForeignKey(
        ContentType, related_name="experiment_settings_out", null=True, blank=True)

    #: json-encoded dictionary of filters on the table corresponding to
    #: content_type
    content_filter = models.TextField(default='{}')

    #: attr on the output type that points to the input content_type
    #: e.g. 'photo'
    out_content_attr = models.CharField(max_length=127, blank=True)

    #: minumum number of outputs per input per HIT
    #: (usually 1, except for segmentation)
    #: constraint: must be >= 1
    out_count_ratio = models.IntegerField(default=1)

    #: vertical size of the frame in pixels
    frame_height = models.IntegerField(default=800)

    #: metadata shown to workers when listing tasks on the marketplace
    title = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    keywords = models.CharField(max_length=1000)  # comma-separated list

    #: time (seconds) the worker has to complete the task
    duration = models.IntegerField(default=30 * 60)

    #: time (seconds) that the task is on the market
    lifetime = models.IntegerField(default=3600 * 24 * 31)

    #: time (seconds) until the task is automatically approved
    auto_approval_delay = models.IntegerField(default=2592000)

    #: at most this number of hits will be live at one time
    max_active_hits = models.IntegerField(default=0)

    #: at most this number of hits will exist in total
    max_total_hits = models.IntegerField(default=0)

    #: json-encoded dictionary: amazon-enforced limits on worker history (e.g.
    #: assignment accept rate)
    qualifications = models.TextField(default='{}')

    #: json-encoded dictionary: constraints on how little work can be done (e.g.
    #: min number of polygons or min. vertices).  this is not related to amazon
    #: qualifications.
    requirements = models.TextField(default='{}')

    def out_content_model(self):
        return self.out_content_type.model_class()

    def content_model(self):
        return self.content_type.model_class()


class Experiment(MtModelBase):
    """ High-level separation of HITs.  """

    # settings used for generating new HITs
    new_hit_settings = models.ForeignKey(
        ExperimentSettings, related_name="experiments", null=True, blank=True)

    #: number of sentinel contents given to each user
    #: (not stored with the ExperimentSettings object since this
    #: is used dynamically as the assignments are created)
    test_contents_per_assignment = models.IntegerField(default=0)

    #: slug: url and filename-safe name of this experiment.  the slug and
    #: variant together are unique.  this is also the name used for templates.
    slug = models.CharField(max_length=32, db_index=True)

    #: variant: json-encoded data parameterizing the experiment
    #: (e.g. which environment map to use).  the slug and variant together are
    #: unique.  parameters like how many shapes to submit are part of the
    #: experiment settings.
    variant = models.TextField(blank=True)

    #: identifier for determining which experiments have been completed
    #: (if two experiments share this field, then an item completed under one
    #: experiment will count as completed under the other experiment)
    completed_id = models.CharField(max_length=32)

    #: version number
    #:     ``1``: unchanged from OpenSurfaces project
    #:     ``2``: updated in Intrinsic Images project
    version = models.IntegerField(default=1)

    #: whether there is a dedicated tutorial for this task
    has_tutorial = models.BooleanField(default=False)

    #: directory where the template is stored.  the templates for each
    #: experiment are constructed as follows:
    #:
    #: ::
    #:
    #:     {template_dir}/{slug}.html              -- mturk task
    #:     {template_dir}/{slug}_inst_content.html -- instructions page (just the
    #:                                                content)
    #:     {template_dir}/{slug}_inst.html         -- instructions (includes
    #:                                                _inst_content.html)
    #:     {template_dir}/{slug}_tut.html          -- tutorial (if there is one)
    template_dir = models.CharField(
        max_length=255, default='mturk/experiments')

    #: if True, something was submitted since the last time CUBAM was run on
    #: this experiment.
    cubam_dirty = models.BooleanField(default=False)

    #: name of the module where functions like configure_experiments are held,
    #: usually called "<some_app>.experiments"
    module = models.CharField(max_length=255, blank=True)

    #: name of the attribute on each example where good and bad should be
    #: grouped together.  example: if you have good and bad BRDFs for a shape,
    #: and the BRDF points to the shape with the name 'shape', then this
    #: field would could set to 'shape'.
    examples_group_attr = models.CharField(max_length=255, blank=True)

    def save(self, *args, **kwargs):
        # fill in missing fields
        if self.variant is None:
            self.variant = ''
        elif not isinstance(self.variant, basestring):
            self.variant = json.dumps(self.variant)
        if not self.completed_id:
            self.completed_id = hashlib.md5('%s$%s' % (
                self.slug, self.variant)).hexdigest()
        super(Experiment, self).save(*args, **kwargs)

    def get_module(self):
        if not hasattr(self, '_module'):
            if self.module:
                self._module = import_module(self.module)
            else:
                self._module = None
        return self._module

    def set_new_hit_settings(self, **kwargs):
        """ Update the new_hit_settings member """
        if self.new_hit_settings is None:
            self.new_hit_settings = ExperimentSettings.objects.create(**kwargs)
        else:
            args = kwargs.copy()
            for f in ExperimentSettings._meta.fields:
                k = f.name
                if k != 'id' and k not in args:
                    args[k] = getattr(self.new_hit_settings, k)
            self.new_hit_settings = ExperimentSettings.objects.create(**args)
        self.save()

    def external_task_url(self):
        if settings.ENABLE_SSL:
            # SSL certificates are under the host name
            base_url = settings.SITE_URL
        else:
            # use raw IP for faster page loads -- a fresh DNS lookup can take
            # as long as 6s in India for some DNS servers
            base_url = 'http://' % settings.SERVER_IP
        return base_url + reverse('mturk-external-task', args=(self.id,))

    def content_priority(self, obj):
        """ Returns the priority to assign to object obj """

        # let the experiment specify a priority
        module = self.get_module()
        if module and hasattr(module, 'content_priority'):
            return module.content_priority(self, obj)

        # otherwise, try and guess a priority by assuming it's an
        # OpenSurfaces object:

        if hasattr(obj, 'substance'):
            shape = obj
            if shape.substance and shape.substance.name == 'Painted':
                return shape.num_vertices - 1000000
            else:
                return shape.num_vertices
        elif hasattr(obj, 'shape'):
            shape = obj.shape
            if shape.substance and shape.substance.name == 'Painted':
                return shape.num_vertices - 1000000
            else:
                return shape.num_vertices
        elif hasattr(obj, 'scene_category'):
            if obj.scene_category_correct_score > 0:
                score = obj.scene_category_correct_score + obj.publishable_score()
                # demote certain scenes
                if obj.scene_category.name in ('bathroom', 'staircase'):
                    score -= 10
                return score
            else:
                return -10
        elif hasattr(obj, 'photo'):
            photo = obj.photo
            score = photo.publishable_score()
            #if photo.num_vertices:
                #score += photo.num_vertices
            if photo.scene_category_correct_score:
                score += photo.scene_category_correct_score
                if photo.scene_category.name in ('bathroom', 'staircase'):
                    score -= 10
            if not photo.num_intrinsic_points:
                score += photo.num_intrinsic_points
            if not photo.num_intrinsic_comparisons:
                score += photo.num_intrinsic_comparisons
            if not photo.scene_category_correct:
                score -= 10000
            return score
        else:
            return 0

    def template_name(self):
        return os.path.join(self.template_dir, self.slug)

    def __unicode__(self):
        if self.variant:
            return '%s (%s)' % (self.slug, self.variant)
        else:
            return self.slug

    class Meta:
        ordering = ["slug", "variant"]
        unique_together = ("slug", "variant")


class PendingContent(EmptyModelBase):
    """
    A generic wrapper that keeps track of how many outputs need to be generated
    for an object/experiment pair, and how many are scheduled for future
    generation.  Right now, outputs are generated only by HITs.
    """

    #: experiment that will be run on this object
    experiment = models.ForeignKey(Experiment, related_name='pending_contents')

    #: maximum number of outputs we will need.
    #: set to 0 if this does not pass the filter.
    num_outputs_max = models.IntegerField(default=0)

    #: number of outputs completed so far
    num_outputs_completed = models.IntegerField(default=0, db_index=True)

    #: number of outputs that are scheduled to be completed.
    #: as HIT assignments are submitted, this number is updated.
    num_outputs_scheduled = models.IntegerField(default=0)

    #: HITs that are/were scheduled to generate more outputs (can be expired)
    hits = models.ManyToManyField('MtHit', related_name='pending_contents')

    #: contents are sorted by num_outputs_max, then priority
    priority = models.FloatField(default=0, db_index=True)

    #: generic relation to the object (e.g. Photo, MaterialShape) being studied
    content_type = models.ForeignKey(ContentType, db_index=True)
    object_id = models.PositiveIntegerField(db_index=True)
    content = generic.GenericForeignKey('content_type', 'object_id')

    class Meta:
        unique_together = (('experiment', 'content_type', 'object_id'),)

    def num_to_schedule(self):
        return (self.num_outputs_max - self.num_outputs_completed -
                self.num_outputs_scheduled)

    def __unicode__(self):
        return '%s: %s/%s' % (self.experiment, self.num_outputs_completed,
                              self.num_outputs_max)


@receiver(hit_expired)
def pending_content_hit_expired(sender, instance, **kwargs):
    """" Updates pending content when a HIT is expired """

    hit = instance
    ratio = hit.hit_type.experiment.new_hit_settings.out_count_ratio
    for pc in hit.pending_contents.all():
        # optimistic estimate of open assignments that we are now expiring
        delta = hit.max_assignments - hit.assignments.filter(
            status__isnull=False).count()
        if delta > 0:
            PendingContent.objects.filter(id=pc.id).update(
                num_outputs_scheduled=F('num_outputs_scheduled') -
                delta * ratio
            )


@receiver(marked_invalid)
def pending_content_marked_invalid(sender, instance, **kwargs):
    # unschedule downstream experiments
    ct = ContentType.objects.get_for_model(instance)
    PendingContent.objects \
        .filter(content_type=ct, object_id=instance.id) \
        .update(num_outputs_max=0)


class ExperimentExample(EmptyModelBase):
    """ An example shown with the example for illustration """

    experiment = models.ForeignKey(Experiment, related_name='examples')

    # whether this is an example to mimic or avoid
    good = models.BooleanField(default=False)

    # generic relation to the object shown (e.g. MaterialShapeQuality, ...)
    content_type = models.ForeignKey(ContentType)
    object_id = models.PositiveIntegerField()
    content = generic.GenericForeignKey('content_type', 'object_id')

    #def get_thumb_template(self):
        #return 'murk/admin/example_thumb.html'


class ExperimentTestContent(EmptyModelBase):
    """ A sentinel object distributed to users where the answer is known """

    #: experiment that this is associated with
    experiment = models.ForeignKey(Experiment, related_name='test_contents')

    #: higher priority contents are shown first
    priority = models.FloatField(default=0, db_index=True)

    #: generic relation to the object being tested.  the correct answer is
    #: assumed to be attached to this object.
    content = generic.GenericForeignKey('content_type', 'object_id')
    content_type = models.ForeignKey(ContentType)
    object_id = models.PositiveIntegerField()


class ExperimentTestContentResponse(EmptyModelBase):
    """ A user's response to an ExperimentTestContent """

    #: content being tested
    test_content = models.ForeignKey(
        ExperimentTestContent, related_name='responses')

    #: worker/experiment pair doing the test
    experiment_worker = models.ForeignKey(
        'mturk.ExperimentWorker', related_name='test_content_responses')

    #: assignment where this was submitted
    assignment = models.ForeignKey(
        'mturk.MtAssignment', related_name='test_content_responses')

    #: did they give the correct answer?
    correct = models.BooleanField(default=False)

    #: user response
    response = models.TextField()


class ExperimentWorker(EmptyModelBase):
    """ The stats for a worker and a given experiment """

    #: Experiment being done
    experiment = models.ForeignKey(Experiment, related_name='experiment_workers')

    #: Worker performing the experiment
    worker = models.ForeignKey(UserProfile, related_name='experiment_workers')

    #: If the experiment has a tutorial, this records whether the tutorial was
    #: completed.
    tutorial_completed = models.BooleanField(default=False)

    #: If true, automatically approve submissions by this worker 5 minutes
    #: after they submit, with the message in ``auto_approve_message``
    auto_approve = models.BooleanField(default=False)

    #: Feedback to give when auto-approving.  If blank, it will be "Thank you!".
    auto_approve_message = models.TextField(blank=True)

    #: block user (only locally; not on mturk)
    blocked = models.BooleanField(default=False)

    #: reason for blocking -- message to be displayed to the user
    blocked_reason = models.TextField(blank=True)

    BLOCKED_METHODS = (
        ('A', 'Admin'),
        ('T', 'Low test accuracy'),
    )
    #: method for setting block
    blocked_method = models.CharField(
        max_length=1, choices=BLOCKED_METHODS, null=True, blank=True)

    #: total number of correct sentinel answers
    num_test_correct = models.IntegerField(default=0)
    #: total number of incorrect sentinel answers
    num_test_incorrect = models.IntegerField(default=0)

    def test_accuracy_str(self):
        """ Helper for templates """
        if self.num_test_correct is None or self.num_test_incorrect is None:
            return "N/A"
        else:
            tot = self.num_test_correct + self.num_test_incorrect
            if tot:
                return "%s/%s (%s%%)" % (
                    self.num_test_correct, tot,
                    round(100 * self.num_test_correct / tot, 1))
            else:
                return "N/A"

    def set_auto_approve(self, message='', save=True):
        self.blocked = False
        self.blocked_reason = ''
        self.blocked_method = None
        self.auto_approve = True
        self.auto_approve_message = message
        if save:
            self.save()

    def block(self, reason='', method='A', all_tasks=False,
              report_to_mturk=False, save=True):
        """ Prevent a user from working on tasks in the future.  Unless
        ``report_to_mturk`` is set, This is only local to your server and the
        worker's account on mturk is not flagged.

        :param reason: A message to display to the user when they try and
            complete tasks in the future.

        :param all_tasks: if ``True``, block the worker from all experiments.

        :param report_to_mturk: if ``True``, block the worker from all experiments,
            and also block them from MTurk.  This will flag the user's account, so only
            use this for malicious users who are clearly abusing your task.
        """

        self.blocked = True
        self.blocked_reason = reason
        self.blocked_method = method
        self.auto_approve = False
        self.auto_approve_message = ''
        if save:
            self.save()
        if all_tasks or report_to_mturk:
            self.worker.block(reason=reason, save=True)
        if report_to_mturk:
            get_mturk_connection().block_worker(
                worker_id=self.worker.mturk_worker_id,
                reason=reason)

    class Meta:
        unique_together = ('experiment', 'worker')


class MtQualification(MtModelBase):
    """
    Custom qualification defined by us.
    """

    #: MTurk id
    id = models.CharField(max_length=128, primary_key=True)

    #: whether status is Active or Inactive
    active = models.BooleanField(default=True)

    #: The name of the Qualification type. The type name is used to identify the
    #: type, and to find the type using a Qualification type search.
    name = models.CharField(max_length=32)

    #: One or more words or phrases that describe theQualification type,
    #: separated by commas. The Keywords make the type easier to find using a
    #: search.
    keywords = models.CharField(max_length=1024)

    #: A long description for the Qualification type.
    description = models.CharField(max_length=1024)

    #: Specifies that requests for the Qualification type are granted
    #: immediately, without prompting the Worker with a Qualification test.
    auto_granted = models.BooleanField(default=False)

    #: value to set when auto-granting
    auto_granted_value = models.IntegerField(default=1)

    #: The amount of time, in seconds, Workers must wait after taking the
    #: Qualification test before they can take it again. Workers can take a
    #: Qualification test multiple times if they were not granted the
    #: Qualification from a previous attempt, or if the test offers a gradient
    #: score and they want a better score.
    retry_delay = models.IntegerField(null=True, blank=True)

    # shortened name for filtering
    slug = models.CharField(max_length=16, unique=True)

    def save(self, *args, **kwargs):
        if not self.id:
            response = get_mturk_connection().create_qualification_type(
                name=self.name,
                description=self.description,
                status="Active" if self.active else "Inactive",
                keywords=self.keywords,
                retry_delay=self.retry_delay,
                auto_granted=self.auto_granted,
                auto_granted_value=self.auto_granted_value
            )

            self.id = extract_mturk_attr(response, 'QualificationTypeId')
        else:
            get_mturk_connection().update_qualification_type(
                qualification_type_id=self.id,
                description=self.description,
                status="Active" if self.active else "Inactive",
                retry_delay=self.retry_delay,
                auto_granted=self.auto_granted,
                auto_granted_value=self.auto_granted_value
            )

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


class MtQualificationAssignment(MtModelBase):

    qualification = models.ForeignKey(
        MtQualification, related_name='assignments')

    worker = models.ForeignKey(
        UserProfile, null=True, blank=True, related_name='qualifications')

    #: integer value assigned to user
    value = models.IntegerField(default=0)

    #: if False, mturk does not know about this record
    granted = models.BooleanField(default=False)

    #: if this was a test, their score
    num_correct = models.IntegerField(null=True, blank=True)
    num_incorrect = models.IntegerField(null=True, blank=True)

    def set_value(self, value=1, save=True):
        dirty = False
        if not self.granted:
            dirty = True
            try:
                get_mturk_connection().assign_qualification(
                    qualification_type_id=self.qualification.id,
                    worker_id=self.worker.mturk_worker_id,
                    value=value, send_notification=True)
                self.granted = True
                self.value = value
            except MTurkRequestError as e:
                if 'QualificationAlreadyExists' in e.body:
                    self.granted = True
                    self.value = None
                else:
                    raise

        if self.value != value:
            dirty = True
            get_mturk_connection().update_qualification_score(
                qualification_type_id=self.qualification.id,
                worker_id=self.worker.mturk_worker_id,
                value=value)
            self.value = value

        if dirty and save:
            self.save()

    def revoke(self, reason=None, save=True):
        if self.granted:
            get_mturk_connection().revoke_qualification(
                subject_id=self.worker.mturk_worker_id,
                qualification_type_id=self.qualification.id,
                reason=reason)

            self.granted = False
            if save:
                self.save()


class MtHitType(MtModelBase):
    """ Contains the metadata for a HIT (corresponds to a MTurk HITType) """

    #: HIT metadata
    experiment = models.ForeignKey(
        Experiment, related_name='hit_types')

    #: Other HIT settings
    experiment_settings = models.ForeignKey(
        ExperimentSettings, related_name='hit_types')

    #: external question info
    external_url = models.CharField(max_length=255)
    #: external question info
    frame_height = models.IntegerField(default=800)

    #: Amazon MTurk fields (fields that are mirrored on the MT database)
    id = models.CharField(max_length=128, primary_key=True)
    title = models.CharField(max_length=255, blank=True)
    description = models.TextField(blank=True)
    reward = models.DecimalField(
        decimal_places=4, max_digits=8, default=Decimal('0.01'))
    duration = models.IntegerField(default=3600)
    keywords = models.CharField(max_length=1000, blank=True)
    auto_approval_delay = models.IntegerField(default=2592000)

    #: bonus for giving feedback
    feedback_bonus = models.DecimalField(
        decimal_places=2, max_digits=8, null=True, blank=True)

    def __unicode__(self):
        return '%s ($%.2f)' % (self.experiment.slug, self.reward)

    def save(self, *args, **kwargs):
        if not self.experiment_settings:
            self.experiment_settings = self.experiment.new_hit_settings
        super(MtHitType, self).save(*args, **kwargs)

    def get_external_question(self):
        return ExternalQuestion(
            external_url=self.external_url,
            frame_height=self.frame_height)

    class Meta:
        verbose_name = "HIT Type"
        verbose_name_plural = "HIT Types"


def get_or_create_hit_type(**kwargs):
    """ Returns a HIT type and also manages attaching the requirements list """

    experiment = kwargs['experiment']
    reqs = kwargs['requirements'] if 'requirements' in kwargs else {}
    quals = kwargs['qualifications'] if 'qualifications' in kwargs else {}

    # unpack from json if string
    if isinstance(reqs, basestring):
        reqs = json.loads(reqs)
    if isinstance(quals, basestring):
        quals = json.loads(quals)

    # disable qualifications on the sandbox
    if settings.MTURK_SANDBOX:
        qual_req = None
    else:
        qual_req = qualification_dict_to_boto(quals)

    # send request to Amazon
    response = get_mturk_connection().register_hit_type(
        title=kwargs['title'],
        description=kwargs['description'],
        reward=kwargs['reward'],
        duration=kwargs['duration'],
        keywords=kwargs['keywords'].split(','),
        approval_delay=kwargs['auto_approval_delay'],
        qual_req=qual_req)
    hit_type_id = extract_mturk_attr(response, 'HITTypeId')

    try:
        hit_type = MtHitType.objects.get(pk=hit_type_id)

        # Make sure the other arguments are the same.  Since quals are held by amazon,
        # a new id will be returned if any qual changes.
        if (hit_type.experiment != experiment):
            raise ValueError("Experiment mismatch -- run mtconfigure again")
        if (hit_type.frame_height != kwargs['frame_height']):
            raise ValueError("Frame height mismatch -- run mtconfigure again")
        if (hit_type.feedback_bonus != kwargs['feedback_bonus']):
            raise ValueError("Feedback bonus mismatch -- run mtconfigure again")
        if (hit_type.external_url != experiment.external_task_url()):
            raise ValueError("External URL mismatch -- run mtconfigure again")
        if hit_type.requirements.count() != len(reqs):
            raise ValueError("Requirement mismatch -- run mtconfigure again")
        for r in hit_type.requirements.all():
            if r.name not in reqs:
                raise ValueError("Requirement mismatch -- run mtconfigure again")
            elif r.value != reqs[r.name]:
                raise ValueError("Requirement mismatch -- run mtconfigure again")

        # full match -- return object
        return hit_type
    except MtHitType.DoesNotExist:
        # new hit type -- prepare arguments
        ht_fields = [f.name for f in MtHitType._meta.fields]
        ht_args = {k: v for k, v in kwargs.iteritems() if k in ht_fields}
        ht_args['id'] = hit_type_id
        ht_args['external_url'] = experiment.external_task_url()

        # create qualification and requirement objects
        hit_type = MtHitType.objects.create(**ht_args)
        for name, value in reqs.iteritems():
            hit_type.requirements.create(name=name, value=value)
        for name, value in quals.iteritems():
            hit_type.qualifications.create(name=name, value=value)

        return hit_type


def get_or_create_hit_type_from_experiment(experiment):
    """ Creates a MtHitType from an ExperimentSettings object """

    exp_settings = experiment.new_hit_settings
    if not exp_settings:
        raise ValueError("Missing experiment settings")

    # starter args
    hit_type_args = {
        'experiment': experiment,
        'experiment_settings': exp_settings
    }

    # copy over all fields
    for field in ExperimentSettings._meta.fields:
        if field.name not in ['added', 'updated', 'experiment',
                              'experiment_settings']:
            val = getattr(exp_settings, field.name)
            if val is not None:
                hit_type_args[field.name] = val

    # ensure that if the settings change, the hit_type_id also changes
    # (by attaching the modified date to the description)
    hit_type_args['description'] += ' (updated %s)' % exp_settings.updated

    return get_or_create_hit_type(**hit_type_args)


class MtHitQualification(MtModelBase):
    """ Represents a qualification required to start a task """
    hit_type = models.ForeignKey(MtHitType, related_name='qualifications')

    #: either a predefined name or a slug for MtQualification
    name = models.CharField(max_length=64, blank=True)
    value = models.IntegerField()

    def to_boto(self):
        return qualification_to_boto(self)

    def __unicode__(self):
        return '%s = %s' % (self.name, self.value)

    class Meta:
        verbose_name = "HIT Qualification"
        verbose_name_plural = "HIT Qualifications"


class MtHitRequirement(MtModelBase):
    """
    Contains a requirement that needs to be met for a task to be considered complete
    Example: min shapes per photo, min vertices per shape, min total vertices
    """

    hit_type = models.ForeignKey(MtHitType, related_name='requirements')
    name = models.CharField(max_length=64)
    value = models.IntegerField()

    def __unicode__(self):
        return '%s = %s' % (self.name, self.value)

    class Meta:
        verbose_name = "HIT Requirement"
        verbose_name_plural = "HIT Requirements"


class MtHit(MtModelBase):
    """ MTurk HIT (Human Intelligence Task, corresponds to a MTurk HIT object) """

    #: use Amazon's id
    id = models.CharField(max_length=128, primary_key=True)
    hit_type = models.ForeignKey(MtHitType, related_name='hits')

    lifetime = models.IntegerField(null=True, blank=True)
    expired = models.BooleanField(default=False)
    sandbox = models.BooleanField(default=(lambda: settings.MTURK_SANDBOX))

    #: if True, at least one assignment has been submitted (useful for filtering)
    any_submitted_assignments = models.BooleanField(default=False)
    #: if True, all assignments have been submitted (useful for filtering)
    all_submitted_assignments = models.BooleanField(default=False)

    # assignment data -- only updated after a sync
    max_assignments = models.IntegerField(default=1)
    num_assignments_available = models.IntegerField(null=True, blank=True)
    num_assignments_completed = models.IntegerField(null=True, blank=True)
    num_assignments_pending = models.IntegerField(null=True, blank=True)

    #: number of people who viewed this HIT but could not accept (e.g. no WebGL)
    incompatible_count = models.IntegerField(default=0)
    #: number of people who viewed this HIT but could have accepted
    compatible_count = models.IntegerField(default=0)

    #: mininum number of objects we expect to generate per input content
    out_count_ratio = models.IntegerField(default=1)

    #: cache the number of contents
    num_contents = models.IntegerField(null=True, blank=True)

    #: dictionary converting MTurk attr names to model attr names
    str_to_attr = {
        'LifetimeInSeconds': 'lifetime',
        'MaxAssignments': 'max_assignments',
        'NumberOfAssignmentsAvailable': 'num_assignments_available',
        'NumberOfAssignmentsCompleted': 'num_assignments_completed',
        'NumberOfAssignmentsPending': 'num_assignments_pending',
        'expired': 'expired'  # yes, this is lower case in boto (and documented)
    }

    HIT_STATUSES = (
        ('A', 'Assignable'),
        ('U', 'Unassignable'),
        ('R', 'Reviewable'),
        ('E', 'Reviewing'),
        ('D', 'Disposed'),
    )
    str_to_hit_status = dict((v, k) for (k, v) in HIT_STATUSES)
    hit_status_to_str = dict((k, v) for (k, v) in HIT_STATUSES)
    hit_status = models.CharField(
        max_length=1, choices=HIT_STATUSES, null=True, blank=True)

    REVIEW_STATUSES = (
        ('N', 'NotReviewed'),
        ('M', 'MarkedForReview'),
        ('A', 'ReviewedAppropriate'),
        ('I', 'ReviewedInappropriate')
    )
    str_to_review_status = dict((v, k) for (k, v) in REVIEW_STATUSES)
    review_status_to_str = dict((k, v) for (k, v) in REVIEW_STATUSES)
    review_status = models.CharField(
        max_length=1, choices=REVIEW_STATUSES, null=True, blank=True)

    def save(self, *args, **kwargs):
        if not self.id:
            self.sandbox = settings.MTURK_SANDBOX
            self.hit_status = 'A'

            # send request to Amazon
            response = get_mturk_connection().create_hit(
                hit_type=self.hit_type.id,
                question=self.hit_type.get_external_question(),
                lifetime=datetime.timedelta(seconds=self.lifetime),
                max_assignments=self.max_assignments,
                annotation=json.dumps(
                    {
                        u'experiment_id': self.hit_type.experiment.id,
                        u'num_contents': self.contents.count(),
                    })
            )
            self.id = extract_mturk_attr(response, 'HITId')
        elif self.hit_status != 'D':  # 'D': disposed
            # add hit type if missing and not disposed
            aws_hit = None
            if not self.hit_type:
                aws_hit = self.get_aws_hit()
                defaults = {}
                #defaults = {'external_url':
                self.hit_type = MtHitType.objects.get_or_create(
                    id=aws_hit.HitTypeId, defaults=defaults)[0]

            # add any missing attributes
            for k, v in MtHit.str_to_attr.iteritems():
                if getattr(self, v) is None:
                    if not aws_hit:
                        aws_hit = self.get_aws_hit()
                    if hasattr(aws_hit, k):
                        setattr(self, v, getattr(aws_hit, k))

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

    def sync_status(self, hit=None, sync_assignments=True):
        """ Set this instance status to match the Amazon status.  """

        connection = get_mturk_connection()
        if not hit:
            hit = self.get_aws_hit(connection)
        if not self.hit_type:
            self.hit_type = MtHitType.objects.get(id=hit.HitTypeId)
        self.hit_status = MtHit.str_to_hit_status[hit.HITStatus]
        self.review_status = MtHit.str_to_review_status[hit.HITReviewStatus]
        for k, v in MtHit.str_to_attr.iteritems():
            if hasattr(hit, k):
                setattr(self, v, extract_mturk_attr(hit, k))

        if sync_assignments:
            assignment_ids = []

            num_submitted = 0
            page = 1
            while True:
                page_data = connection.get_assignments(
                    self.id, page_size=10, page_number=page)

                for data in page_data:
                    assignment_ids.append(data.AssignmentId)
                    assignment = self.assignments \
                        .get_or_create(id=data.AssignmentId)[0]
                    assignment.sync_status(data)
                    if assignment.status is not None:
                        num_submitted += 1

                if (page_data.TotalNumResults < page * 10):
                    page += 1
                else:
                    break

            # extra assignments should have None status
            self.assignments.exclude(id__in=assignment_ids) \
                .update(status=None)

            # NOTE: the int() is needed since self.max_assignments
            # is set above, and is still a unicode string at this point
            self.any_submitted_assignments = (num_submitted > 0)
            self.all_submitted_assignments = (
                num_submitted >= int(self.max_assignments))

        self.save()

    def get_aws_hit(self, connection=None):
        if not connection:
            connection = get_mturk_connection()
        return connection.get_hit(self.id, response_groups=[
            "Minimal", "HITDetail", "HITAssignmentSummary"
        ])[0]

    def expire(self, data=None):
        """ Expire this HIT -- no new workers can accept this HIT, but existing
        workers can finish """

        if self.expired:
            self.sync_status(data)
        if not self.expired:
            print 'Expiring: %s' % self.id
            get_mturk_connection().expire_hit(self.id)
            self.expired = True
            self.save()

            # signal
            hit_expired.send(sender=self.__class__, instance=self)

            return True
        return False

    def dispose(self, data=None):
        """ Dispose this HIT -- finalize all approve/reject decisions """

        if self.assignments.filter(status='S').exists():
            print "Cannot dispose HIT with un-approved assignments: %s" % self.id
            return False
        if self.hit_status == 'D':
            self.sync_status(data)
        if self.hit_status != 'D':
            print 'Disposing: %s' % self.id
            get_mturk_connection().expire_hit(self.id)
            get_mturk_connection().dispose_hit(self.id)
            self.expired = True
            self.hit_status = 'D'
            self.save()
            return True

    def __unicode__(self):
        return self.id

    class Meta:
        verbose_name = "HIT"
        verbose_name_plural = "HITs"


class MtHitContent(EmptyModelBase):
    """ An object attached to a HIT for labeling, e.g. a photo or shape """

    #: the HIT that contains this object
    hit = models.ForeignKey(MtHit, related_name='contents')

    #: generic relation to an object to be shown (e.g. Photo, Shape)
    content_type = models.ForeignKey(ContentType)
    object_id = models.PositiveIntegerField()
    content = generic.GenericForeignKey('content_type', 'object_id')


class MtAssignment(MtModelBase):
    """
    An assignment is a worker assigned to a HIT
    NOTE: Do not create this -- instead call sync_status() on a MtHit object
    """

    #: use the Amazon-provided ID as our ID
    id = models.CharField(max_length=128, primary_key=True)

    hit = models.ForeignKey(MtHit, related_name='assignments')
    worker = models.ForeignKey(UserProfile, null=True, blank=True)

    #: sentinel test contents
    test_contents = models.ManyToManyField(
        ExperimentTestContent, related_name='assignments')
    #: number of ``test_contents``.  None: not yet prepared.
    num_test_contents = models.IntegerField(null=True, blank=True)

    #: number of sentinel correct answers
    num_test_correct = models.IntegerField(null=True, blank=True)
    #: number of sentinel incorrect answers
    num_test_incorrect = models.IntegerField(null=True, blank=True)

    #: set by Amazon and updated using sync_status
    accept_time = models.DateTimeField(null=True, blank=True)
    #: set by Amazon and updated using sync_status
    submit_time = models.DateTimeField(null=True, blank=True)
    #: set by Amazon and updated using sync_status
    approval_time = models.DateTimeField(null=True, blank=True)
    #: set by Amazon and updated using sync_status
    rejection_time = models.DateTimeField(null=True, blank=True)
    #: set by Amazon and updated using sync_status
    deadline = models.DateTimeField(null=True, blank=True)
    #: set by Amazon and updated using sync_status
    auto_approval_time = models.DateTimeField(null=True, blank=True)

    str_to_attr = {
        'AcceptTime': 'accept_time',
        'AutoApprovalTime': 'auto_approval_time',
        'SubmitTime': 'submit_time',
        'Deadline': 'deadline',
        'ApprovalTime': 'approval_time',
        'RejectionTime': 'rejection_time',
    }

    #: updated by our server
    has_feedback = models.BooleanField(default=False)
    feedback = models.TextField(blank=True)
    feedback_bonus_given = models.BooleanField(default=False)

    #: bonus for good job (sum of all bonuses given)
    bonus = models.DecimalField(
        decimal_places=2, max_digits=8, null=True, blank=True)

    #: message(s) given to the user after different operations.
    #: if multiple messages are sent, they are separated by '\n'.
    bonus_message = models.TextField(blank=True)
    approve_message = models.TextField(blank=True)
    reject_message = models.TextField(blank=True)

    action_log = models.TextField(blank=True)
    partially_completed = models.BooleanField(default=False)

    #: user-agent string from last submit.
    user_agent = models.TextField(blank=True)

    #: user screen size
    screen_width = models.IntegerField(null=True, blank=True)
    #: user screen size
    screen_height = models.IntegerField(null=True, blank=True)

    #: json-encoded request.POST dictionary from last submit.
    post_data = models.TextField(blank=True)

    #: json-encoded request.META dictionary from last submit.
    #: see: https://docs.djangoproject.com/en/dev/ref/request-response/
    post_meta = models.TextField(blank=True)

    #: estimate of the time spent doing the HIT
    time_ms = models.IntegerField(null=True, blank=True)

    #: estimate of the time spent doing the HIT, excluding time where the user
    #: is in another window
    time_active_ms = models.IntegerField(null=True, blank=True)

    #: estimate of how long the page took to load; note that this ignores server
    #: response time, so this will always be ~300ms smaller than reality.
    time_load_ms = models.IntegerField(null=True, blank=True)

    #: estimate of the wage from this HIT
    wage = models.FloatField(null=True, blank=True)

    #: if true, then this HIT was manually rejected and
    #: should not be un-rejected
    manually_rejected = models.BooleanField(default=False)

    #: If ``True``, then the async task (``mturk.tasks.mturk_submit_task``) has
    #: finished processing what the user submitted.  Note that there is a period
    #: of time (sometimes up to an hour depending on the celery queue length)
    #: where the assignment is submitted (``status == 'S'``) but the responses
    #: are not inserted into the database.
    submission_complete = models.BooleanField(default=False)

    ASSIGNMENT_STATUSES = (
        ('S', 'Submitted'),
        ('A', 'Approved'),
        ('R', 'Rejected'),
    )
    str_to_status = dict((v, k) for (k, v) in ASSIGNMENT_STATUSES)
    status_to_str = dict((k, v) for (k, v) in ASSIGNMENT_STATUSES)
    status = models.CharField(
        max_length=1, choices=ASSIGNMENT_STATUSES,
        null=True, blank=True)

    def time_s(self):
        """ Helper for templates """
        return int(round(self.time_ms / 1000.0)) if self.time_ms else None

    def time_active_s(self):
        """ Helper for templates """
        return int(round(self.time_active_ms / 1000.0)) if self.time_active_ms else None

    def time_load_s(self):
        """ Helper for templates """
        return (self.time_load_ms / 1000.0) if self.time_load_ms else None

    def test_accuracy_str(self):
        """ Helper for templates """
        if self.num_test_correct is None or self.num_test_incorrect is None:
            return "N/A"
        else:
            tot = self.num_test_correct + self.num_test_incorrect
            if tot:
                return "%s/%s (%s%%)" % (
                    self.num_test_correct, tot,
                    round(100 * self.num_test_correct / tot, 1))
            else:
                return "N/A"

    def test_content_responses_prefetch(self):
        return self.test_content_responses.all().prefetch_related(
            'test_content__content')

    def time_active_percent(self):
        return 100 * self.time_active_ms / self.time_ms if self.time_active_ms and self.time_ms else None

    def user_agent_parsed(self):
        return user_agent_parser.Parse(self.user_agent)

    def status_str(self):
        if self.status:
            status_str = MtAssignment.status_to_str[self.status]
            if self.bonus:
                return status_str + " + Bonus"
            else:
                return status_str
        else:
            return 'Not submitted'

    def status_class_css(self):
        if self.status == 'S':
            return ''
        elif self.status == 'A':
            return 'success'
        elif self.status == 'R':
            return 'warning'
        else:
            return 'inverse'

    def experiment_worker(self):
        """ Returns the ExperimentWorker associated with this assignment """
        if not hasattr(self, '_experiment_worker'):
            self._experiment_worker, _ = ExperimentWorker.objects.get_or_create(
                experiment=self.hit.hit_type.experiment,
                worker=self.worker)
        return self._experiment_worker

    def approve(self, feedback=None, handle_bonus=True, save=True):
        """ Send command to Amazon approving this assignment """

        if self.manually_rejected:
            print "This Assignment (%s) was manually rejected -- not un-rejecting" % self.id
            return

        if self.status == 'A':
            self.hit.sync_status()
            if self.status == 'A':
                return

        # some nice default messages
        if not feedback:
            feedback = "Thank you!"

        if self.status == 'R':
            print 'Un-rejecting assignment: %s, experiment: %s, expired: %s' % (
                self.id, self.hit.hit_type.experiment.slug, self.hit.expired)
            if not feedback:
                feedback = "We have un-rejected this assignment and approved it.  We are very sorry."
            self.reject_message = feedback
            get_mturk_connection().approve_rejected_assignment(
                assignment_id=self.id, feedback=feedback)
        else:
            print 'Approving assignment: %s, experiment: %s, expired: %s' % (
                self.id, self.hit.hit_type.experiment.slug, self.hit.expired)
            self.approve_message = feedback
            get_mturk_connection().approve_assignment(
                assignment_id=self.id, feedback=feedback)

        self.status = 'A'

        if handle_bonus and self.needs_feedback_bonus():
            self.grant_feedback_bonus(save=False)

        if save:
            self.save()

    def reject(self, feedback=None, force=False, save=True):
        """ Send command to Amazon approving this assignment

        :param feedback:
            message shown to the user
        :param force:
            if the user has ``always_approve=True``, then this method will not
            actually reject the assignment unless ``force=True``
        :param save:
            whether to save the result in the database (should always be
            ``True``) unless you are already saving the model again shortly
            after.
        """
        if not feedback:
            feedback = "I'm sorry but you made too many mistakes."

        if self.status == 'A' or self.status == 'R':
            self.hit.sync_status()
            if self.status == 'A' or self.status == 'R':
                return

        if self.worker.always_approve and not force:
            print 'Not rejecting assignment %s since force=False and worker has always_approve=True' % self.id
            return

        print 'Rejecting assignment: %s, experiment: %s, expired: %s' % (
            self.id, self.hit.hit_type.experiment.slug, self.hit.expired)
        get_mturk_connection().reject_assignment(
            assignment_id=self.id, feedback=feedback)
        self.reject_message = feedback
        self.status = 'R'
        if save:
            self.save()

    def needs_feedback_bonus(self):
        """ True if a bonus is deserved but none is received """
        return (self.has_feedback and (not self.feedback_bonus_given) and
                (self.feedback is not None) and len(self.feedback) > 0 and
                self.hit.hit_type.feedback_bonus)

    def grant_bonus(self, price, reason, save=True):
        if self.status == 'S':
            self.approve(feedback=reason)

        print 'Granting bonus: %s, price: $%s, reason: %s' % (self.id, price, reason)
        connection = get_mturk_connection()
        connection.grant_bonus(
            worker_id=self.worker.mturk_worker_id,
            assignment_id=self.id,
            bonus_price=connection.get_price_as_price(price),
            reason=reason
        )

        if self.bonus:
            self.bonus += Decimal(price)
        else:
            self.bonus = Decimal(price)

        if self.bonus_message:
            self.bonus_message += '\n' + reason
        else:
            self.bonus_message = reason

        if save:
            self.save()

    def grant_feedback_bonus(self, save=True):
        """ Give a bonus for submitting feedback """
        price = self.hit.hit_type.feedback_bonus
        print 'Granting feedback bonus: %s, price: $%s' % (self.id, price)
        connection = get_mturk_connection()
        connection.grant_bonus(
            worker_id=self.worker.mturk_worker_id,
            assignment_id=self.id,
            bonus_price=connection.get_price_as_price(price),
            reason="Thank you for submitting feedback!"
        )
        self.feedback_bonus_given = True
        if save:
            self.save()

    def sync_status(self, data):
        """ data: instance of boto.mturk.connection.Assignment """
        self.worker = get_or_create_mturk_worker(data.WorkerId)
        self.status = MtAssignment.str_to_status[data.AssignmentStatus]
        for k, v in MtAssignment.str_to_attr.iteritems():
            if hasattr(data, k):
                attr_value = getattr(data, k)
                setattr(self, v, attr_value)
        self.save()

    def save(self, *args, **kwargs):
        if self.id == 'ASSIGNMENT_ID_NOT_AVAILABLE':
            # just in case anyone tries to save the preview assignment id, abort
            return

        # fix some fields that sometimes get messed up
        if self.time_ms < 0:
            self.time_ms = 0
        if self.time_active_ms < self.time_ms:
            self.time_active_ms = self.time_ms
        if self.submission_complete is None:
            self.submission_complete = False

        if not self.wage and (self.time_ms or self.time_active_ms):
            time = self.time_active_ms if self.time_active_ms else self.time_ms
            self.wage = float(self.hit.hit_type.reward) * 3600000.0 / time

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

    def __unicode__(self):
        return self.id

    class Meta:
        verbose_name = "Assignment"
        verbose_name_plural = "Assignments"
