"""Celery tasks for Member Audit."""
# pylint: disable=redefined-builtin, too-many-lines

import inspect
import random
from typing import Callable, Iterable, Optional

from celery import chain, shared_task

from django.apps import apps
from django.contrib.auth.models import Group, User
from django.db import transaction
from esi.models import Token
from eveuniverse.constants import POST_UNIVERSE_NAMES_MAX_ITEMS
from eveuniverse.models import EveEntity, EveMarketPrice

from allianceauth.notifications import notify
from allianceauth.services.hooks import get_extension_logger
from allianceauth.services.tasks import QueueOnce
from app_utils.esi import EsiErrorLimitExceeded, EsiOffline
from app_utils.logging import LoggerAddTag

from memberaudit import __title__, utils
from memberaudit.app_settings import (
    MEMBERAUDIT_BULK_METHODS_BATCH_SIZE,
    MEMBERAUDIT_LOG_UPDATE_STATS,
    MEMBERAUDIT_TASKS_LOW_PRIORITY,
    MEMBERAUDIT_TASKS_MAX_ASSETS_PER_PASS,
    MEMBERAUDIT_TASKS_NORMAL_PRIORITY,
    MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT,
    MEMBERAUDIT_TASKS_TIME_LIMIT,
    MEMBERAUDIT_UPDATE_STALE_RING_2,
)
from memberaudit.core import data_exporters
from memberaudit.decorators import when_esi_is_available
from memberaudit.helpers import determine_task_priority
from memberaudit.models import (
    Character,
    CharacterAsset,
    CharacterContract,
    CharacterMail,
    CharacterUpdateStatus,
    ComplianceGroupDesignation,
    General,
    Location,
    MailEntity,
)

logger = LoggerAddTag(get_extension_logger(__name__), __title__)

MAX_RETRIES_DEFAULT = 3
MAX_RETRIES_STRUCTURES = 5
MAX_RETRIES_MAIL_ENTITIES = 5

TASK_DEFAULTS = {
    "time_limit": MEMBERAUDIT_TASKS_TIME_LIMIT,
    "max_retries": MAX_RETRIES_DEFAULT,
}
"""Default params for all tasks."""

TASK_DEFAULTS_BIND = {**TASK_DEFAULTS, **{"bind": True}}
"""Default params for tasks that need access to self."""

TASK_DEFAULTS_ONCE = {**TASK_DEFAULTS, **{"base": QueueOnce}}
"""Default params for tasks that need run once only."""

TASK_DEFAULTS_BIND_ONCE = {**TASK_DEFAULTS, **{"bind": True, "base": QueueOnce}}
"""Default params for tasks that need access to self and run once only."""


@shared_task(**TASK_DEFAULTS_ONCE)
def run_regular_updates() -> None:
    """Run regular updates for Member Audit."""
    update_market_prices.apply_async(priority=MEMBERAUDIT_TASKS_LOW_PRIORITY)
    update_all_characters.apply_async(priority=MEMBERAUDIT_TASKS_LOW_PRIORITY)
    if ComplianceGroupDesignation.objects.exists():
        update_compliance_groups_for_all.apply_async(
            priority=MEMBERAUDIT_TASKS_NORMAL_PRIORITY
        )


@shared_task(**TASK_DEFAULTS_BIND_ONCE)
@when_esi_is_available
def update_all_characters(self, force_update: bool = False) -> None:
    """Update all enabled characters from ESI and disable update for orphans.

    Args:
        - force_update: When set to True will always update regardless of stale status
    """
    if MEMBERAUDIT_LOG_UPDATE_STATS:
        stats = CharacterUpdateStatus.objects.statistics()
        logger.info(f"Update statistics: {stats}")

    Character.objects.disable_characters_with_no_owner()

    # start sync for all enabled characters
    enabled_characters = list(
        Character.objects.filter(is_disabled=False).values_list("pk", flat=True)
    )
    if enabled_characters:
        update_type = "forced" if force_update else "normal"
        logger.info(
            "Starting %s updates for %d characters",
            update_type,
            len(enabled_characters),
        )
        priority = determine_task_priority(self) or MEMBERAUDIT_TASKS_LOW_PRIORITY
        for character_pk in enabled_characters:
            update_character.apply_async(
                kwargs={"character_pk": character_pk, "force_update": force_update},
                priority=priority,
            )
    else:
        logger.info("No enabled characters found for update.")


# Main character update tasks


@shared_task(**TASK_DEFAULTS_BIND_ONCE)
def update_character(self, character_pk: int, force_update: bool = False) -> bool:
    """Update all sections of a character from ESI.

    Args:
    - character_pk: PL of character to update
    - force_update: When set to True will always update regardless of stale status

    Returns:
    - True when update was conducted
    - False when no updated was needed
    """
    character: Character = Character.objects.get(pk=character_pk)
    if character.is_orphan:
        logger.info("%s: Skipping update for orphaned character", character)
        return False

    character.reset_token_error_notified_if_status_ok()

    needs_update = force_update | character.is_update_needed()
    if not needs_update:
        logger.info("%s: No update required", character)
        return False

    logger.info(
        "%s: Starting %s character update", character, "forced" if force_update else ""
    )
    character.clear_cache()

    priority = determine_task_priority(self) or MEMBERAUDIT_TASKS_LOW_PRIORITY
    sections_to_update_in_loop = list(
        Character.UpdateSection.enabled_sections_for_simple_update_tasks()
    )
    for section in sorted(sections_to_update_in_loop):
        if force_update or character.is_update_needed_for_section(section):
            update_character_section.apply_async(
                kwargs={
                    "character_pk": character.pk,
                    "section": section,
                    "force_update": force_update,
                    "root_task_id": self.request.parent_id,
                    "parent_task_id": self.request.id,
                },
                priority=priority,
            )

    if force_update or character.is_update_needed_for_section(
        Character.UpdateSection.MAILS
    ):
        update_character_mails.apply_async(
            kwargs={
                "character_pk": character.pk,
                "force_update": force_update,
                "root_task_id": self.request.parent_id,
                "parent_task_id": self.request.id,
            },
            priority=priority,
        )

    if force_update or character.is_update_needed_for_section(
        Character.UpdateSection.CONTACTS
    ):
        update_character_contacts.apply_async(
            kwargs={
                "character_pk": character.pk,
                "force_update": force_update,
                "root_task_id": self.request.parent_id,
                "parent_task_id": self.request.id,
            },
            priority=priority,
        )

    if force_update or character.is_update_needed_for_section(
        Character.UpdateSection.CONTRACTS
    ):
        update_character_contracts.apply_async(
            kwargs={
                "character_pk": character.pk,
                "force_update": force_update,
                "root_task_id": self.request.parent_id,
                "parent_task_id": self.request.id,
            },
            priority=priority,
        )
    if force_update or character.is_update_needed_for_section(
        Character.UpdateSection.ASSETS
    ):
        update_character_assets.apply_async(
            kwargs={
                "character_pk": character.pk,
                "force_update": force_update,
                "root_task_id": self.request.parent_id,
                "parent_task_id": self.request.id,
            },
            priority=priority,
        )

    if (
        force_update
        or character.is_update_needed_for_section(Character.UpdateSection.SKILLS)
        or character.is_update_needed_for_section(Character.UpdateSection.SKILL_SETS)
    ):
        chain(
            update_character_section.si(
                character.pk,
                Character.UpdateSection.SKILLS,
                force_update,
                self.request.parent_id,
                self.request.id,
            ).set(priority=priority),
            update_character_section.si(
                character.pk,
                Character.UpdateSection.SKILL_SETS,
                force_update,
                self.request.parent_id,
                self.request.id,
            ).set(priority=priority),
        ).delay()

    if character.is_shared:
        check_character_consistency.apply_async(
            kwargs={"character_pk": character.pk}, priority=priority
        )

    return True


# Update sections


@shared_task(
    **{
        **TASK_DEFAULTS_ONCE,
        **{
            "once": {
                "keys": ["character_pk", "section", "force_update"],
                "graceful": True,
            }
        },
    }
)
@when_esi_is_available
def update_character_section(
    character_pk: int,
    section: str,
    force_update: bool,
    root_task_id: Optional[str] = None,
    parent_task_id: Optional[str] = None,
) -> None:
    """Update a section for a character from ESI."""
    section = Character.UpdateSection(section)
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    character.reset_update_section(section, root_task_id, parent_task_id)
    logger.info("%s: Updating %s", character, section.label)

    method: Callable = getattr(character, section.method_name)
    method_signature = inspect.signature(method)
    if "force_update" in method_signature.parameters:
        kwargs = {"force_update": force_update}
    else:
        kwargs = {}

    character.perform_update_with_error_logging(
        section=section, method=method, **kwargs
    )
    character.update_section_log_success(section)


@shared_task(**TASK_DEFAULTS_ONCE)
@when_esi_is_available
def update_unresolved_eve_entities() -> None:
    """Bulk resolved all unresolved EveEntity objects in database.

    This task is used by other apps. Do not remove!
    """
    unresolved_ids = EveEntity.objects.filter(name="")[
        :POST_UNIVERSE_NAMES_MAX_ITEMS
    ].values_list("id", flat=True)
    if unresolved_ids:
        updated_count = EveEntity.objects.update_from_esi_by_id(unresolved_ids)
        logger.info("Updating %d unresolved entities from ESI", updated_count)


# Special tasks for updating assets


@shared_task(
    **{
        **TASK_DEFAULTS_BIND_ONCE,
        **{"once": {"keys": ["character_pk", "force_update"], "graceful": True}},
    }
)
def update_character_assets(
    self,
    character_pk: int,
    force_update: bool,
    root_task_id: Optional[str] = None,
    parent_task_id: Optional[str] = None,
) -> None:
    """Update the assets of a character from ESI."""
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    section = Character.UpdateSection.ASSETS
    logger.info("%s: Updating %s", character, section.label)
    character.reset_update_section(
        section=section,
        root_task_id=root_task_id,
        parent_task_id=parent_task_id,
    )
    priority = determine_task_priority(self) or MEMBERAUDIT_TASKS_LOW_PRIORITY
    chain(
        assets_build_list_from_esi.s(character.pk, force_update).set(priority=priority),
        assets_preload_objects.s(character.pk).set(priority=priority),
        assets_create_parents.s(character.pk).set(priority=priority),
    ).delay()


@shared_task(**TASK_DEFAULTS_ONCE)
@when_esi_is_available
def assets_build_list_from_esi(
    character_pk: int, force_update: bool = False
) -> Optional[dict]:
    """Retrieve asset list for a character from ESI and return it
    or return None if asset list is unchanged.
    """
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    assets_data: Optional[dict] = character.perform_update_with_error_logging(
        section=Character.UpdateSection.ASSETS,
        method=character.assets_build_list_from_esi,
        force_update=force_update,
    )
    if assets_data is not None:
        ship_asset_record = character.generate_asset_from_current_ship_and_location()
        if ship_asset_record:
            ship_item_id = ship_asset_record["item_id"]
            if ship_item_id not in assets_data:
                assets_data[ship_item_id] = ship_asset_record
                logger.info("%s: Added current ship to assets", character)

    return assets_data


@shared_task(**TASK_DEFAULTS)
def assets_preload_objects(
    assets_data: Optional[dict], character_pk: int
) -> Optional[dict]:
    """Preload asset objects for a character from ESI."""
    if assets_data is None:
        return None  # Exit when assets are unchanged

    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    character.perform_update_with_error_logging(
        Character.UpdateSection.ASSETS,
        character.assets_preload_objects,
        assets_data,
    )
    return assets_data


@shared_task(**TASK_DEFAULTS_BIND)
def assets_create_parents(
    self, assets_data: Optional[dict], character_pk: int, cycle: int = 1
) -> None:
    """Create the parent assets from an asset list.

    Parent assets are assets attached directly to a Location object (e.g. station)

    This task will recursively call itself until all possible parent assets
    from the asset list have been created.
    Then call another task to create child assets.
    """
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    if assets_data is None:
        character.update_section_log_success(Character.UpdateSection.ASSETS)
        return

    logger.info("%s: Creating parent assets - pass %s", character, cycle)

    new_assets = []
    priority = determine_task_priority(self) or MEMBERAUDIT_TASKS_LOW_PRIORITY
    with transaction.atomic():
        if cycle == 1:
            character.assets.all().delete()

        known_location_ids = set(Location.objects.values_list("id", flat=True))
        parent_asset_ids = {
            item_id
            for item_id, asset_info in assets_data.items()
            if asset_info.get("location_id")
            and asset_info["location_id"] in known_location_ids
        }
        for item_id in parent_asset_ids:
            item = assets_data[item_id]
            new_assets.append(
                CharacterAsset(
                    character=character,
                    item_id=item_id,
                    location_id=item["location_id"],
                    eve_type_id=item.get("type_id"),
                    name=item.get("name"),
                    is_blueprint_copy=item.get("is_blueprint_copy"),
                    is_singleton=item.get("is_singleton"),
                    location_flag=item.get("location_flag"),
                    quantity=item.get("quantity"),
                )
            )
            assets_data.pop(item_id)
            if len(new_assets) >= MEMBERAUDIT_TASKS_MAX_ASSETS_PER_PASS:
                break

        logger.info("%s: Writing %s parent assets", character, len(new_assets))
        # TODO: `ignore_conflicts=True` needed as workaround to compensate for
        # occasional duplicate FK constraint errors. Needs to be investigated
        CharacterAsset.objects.bulk_create(
            new_assets,
            batch_size=MEMBERAUDIT_BULK_METHODS_BATCH_SIZE,
            ignore_conflicts=True,
        )

    if len(parent_asset_ids) > len(new_assets):
        # there are more parent assets to create
        assets_create_parents.apply_async(
            kwargs={
                "assets_data": assets_data,
                "character_pk": character.pk,
                "cycle": cycle + 1,
            },
            priority=priority,
        )
    else:
        # all parent assets created
        if assets_data:
            assets_create_children.apply_async(
                kwargs={"assets_data": assets_data, "character_pk": character.pk},
                priority=priority,
            )
        else:
            character.update_section_log_success(Character.UpdateSection.ASSETS)


@shared_task(**TASK_DEFAULTS_BIND)
def assets_create_children(
    self, assets_data: dict, character_pk: int, cycle: int = 1
) -> None:
    """Create child assets from given asset list.

    Child assets are assets located within other assets (aka containers)

    This task will recursively call itself until all possible assets from the
    asset list are included into the asset tree
    """
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    logger.info("%s: Creating child assets - pass %s", character, cycle)

    # for debug
    # store_list_to_disk(character, assets_data, f"child_asset_list_{cycle}")

    new_assets = []
    priority = determine_task_priority(self) or MEMBERAUDIT_TASKS_LOW_PRIORITY
    with transaction.atomic():
        parent_asset_ids = set(character.assets.values_list("item_id", flat=True))
        child_asset_ids = {
            item_id
            for item_id, item in assets_data.items()
            if item.get("location_id") and item["location_id"] in parent_asset_ids
        }
        for item_id in child_asset_ids:
            item = assets_data[item_id]
            new_assets.append(
                CharacterAsset(
                    character=character,
                    item_id=item_id,
                    parent=character.assets.get(item_id=item["location_id"]),
                    eve_type_id=item.get("type_id"),
                    name=item.get("name"),
                    is_blueprint_copy=item.get("is_blueprint_copy"),
                    is_singleton=item.get("is_singleton"),
                    location_flag=item.get("location_flag"),
                    quantity=item.get("quantity"),
                )
            )
            assets_data.pop(item_id)
            if len(new_assets) >= MEMBERAUDIT_TASKS_MAX_ASSETS_PER_PASS:
                break

        if new_assets:
            logger.info("%s: Writing %s child assets", character, len(new_assets))
            # TODO: `ignore_conflicts=True` needed as workaround to compensate for
            # occasional duplicate FK constraint errors. Needs to be investigated
            CharacterAsset.objects.bulk_create(
                new_assets,
                batch_size=MEMBERAUDIT_BULK_METHODS_BATCH_SIZE,
                ignore_conflicts=True,
            )

    if new_assets and assets_data:
        # there are more child assets to create
        assets_create_children.apply_async(
            kwargs={
                "assets_data": assets_data,
                "character_pk": character.pk,
                "cycle": cycle + 1,
            },
            priority=priority,
        )
    else:
        character.update_section_log_success(Character.UpdateSection.ASSETS)
        if len(assets_data) > 0:
            logger.warning(
                "%s: Failed to add %s assets to the tree: %s",
                character,
                len(assets_data),
                assets_data.keys(),
            )


# Special tasks for updating mail section


@shared_task(
    **{
        **TASK_DEFAULTS_BIND_ONCE,
        **{"once": {"keys": ["character_pk", "force_update"], "graceful": True}},
    }
)
def update_character_mails(
    self,
    character_pk: int,
    force_update: bool,
    root_task_id: Optional[str] = None,
    parent_task_id: Optional[str] = None,
) -> None:
    """Update mails of a character from ESI."""
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    section = Character.UpdateSection.MAILS
    logger.info("%s: Updating %s", character, section.label)
    character.reset_update_section(
        section=section, root_task_id=root_task_id, parent_task_id=parent_task_id
    )
    priority = determine_task_priority(self) or MEMBERAUDIT_TASKS_LOW_PRIORITY
    chain(
        update_character_mailing_lists.si(character.pk, force_update=force_update).set(
            priority=priority
        ),
        update_character_mail_labels.si(character.pk, force_update=force_update).set(
            priority=priority
        ),
        update_character_mail_headers.si(character.pk, force_update=force_update).set(
            priority=priority
        ),
        update_character_mail_bodies.si(character.pk).set(priority=priority),
    ).delay()


@shared_task(**TASK_DEFAULTS_ONCE)
@when_esi_is_available
def update_character_mailing_lists(
    character_pk: int, force_update: bool = False
) -> None:
    """Update mailing list for a character."""
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    character.perform_update_with_error_logging(
        section=Character.UpdateSection.MAILS,
        method=character.update_mailing_lists,
        force_update=force_update,
    )


@shared_task(**TASK_DEFAULTS_ONCE)
@when_esi_is_available
def update_character_mail_labels(character_pk: int, force_update: bool = False) -> None:
    """Update mail labels for a character."""
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    character.perform_update_with_error_logging(
        section=Character.UpdateSection.MAILS,
        method=character.update_mail_labels,
        force_update=force_update,
    )


@shared_task(**TASK_DEFAULTS_ONCE)
@when_esi_is_available
def update_character_mail_headers(
    character_pk: int, force_update: bool = False
) -> None:
    """Update mail headers for a character from ESI."""
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    character.perform_update_with_error_logging(
        section=Character.UpdateSection.MAILS,
        method=character.update_mail_headers,
        force_update=force_update,
    )


@shared_task(**TASK_DEFAULTS_ONCE)
@when_esi_is_available
def update_mail_body_esi(character_pk: int, mail_pk: int):
    """Update the body of a character's mail from ESI."""
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    mail = CharacterMail.objects.get(pk=mail_pk)
    character.perform_update_with_error_logging(
        Character.UpdateSection.MAILS, character.update_mail_body, mail
    )


@shared_task(**TASK_DEFAULTS_BIND_ONCE)
def update_character_mail_bodies(self, character_pk: int, *args, **kwargs) -> None:
    """Update mail bodies for a character from ESI."""
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    mails_without_body_qs = character.mails.filter(body="")
    mails_without_body_count = mails_without_body_qs.count()

    if mails_without_body_count > 0:
        logger.info("%s: Loading %s mail bodies", character, mails_without_body_count)
        priority = determine_task_priority(self) or MEMBERAUDIT_TASKS_LOW_PRIORITY
        for mail in mails_without_body_qs:
            update_mail_body_esi.apply_async(
                kwargs={"character_pk": character.pk, "mail_pk": mail.pk},
                priority=priority,
            )

    # the last task in the chain logs success (if any)
    character.update_section_log_success(Character.UpdateSection.MAILS)


# special tasks for updating contacts


@shared_task(
    **{
        **TASK_DEFAULTS_BIND_ONCE,
        **{"once": {"keys": ["character_pk", "force_update"], "graceful": True}},
    }
)
def update_character_contacts(
    self,
    character_pk: int,
    force_update: bool,
    root_task_id: Optional[str] = None,
    parent_task_id: Optional[str] = None,
) -> None:
    """Update contacts of a character from ESI."""
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    section = Character.UpdateSection.CONTACTS
    character.reset_update_section(
        section=section, root_task_id=root_task_id, parent_task_id=parent_task_id
    )
    logger.info("%s: Updating %s", character, section.label)
    priority = determine_task_priority(self) or MEMBERAUDIT_TASKS_LOW_PRIORITY
    chain(
        update_character_contact_labels.si(character.pk, force_update=force_update).set(
            priority=priority
        ),
        update_character_contacts_2.si(character.pk, force_update=force_update).set(
            priority=priority
        ),
    ).delay()


@shared_task(**TASK_DEFAULTS_ONCE)
@when_esi_is_available
def update_character_contact_labels(
    character_pk: int, force_update: bool = False
) -> None:
    """Update contact labels for a character from ESI."""
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    character.perform_update_with_error_logging(
        section=Character.UpdateSection.CONTACTS,
        method=character.update_contact_labels,
        force_update=force_update,
    )


@shared_task(**TASK_DEFAULTS_ONCE)
@when_esi_is_available
def update_character_contacts_2(character_pk: int, force_update: bool = False) -> None:
    """Update contacts for a character from ESI."""
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    character.perform_update_with_error_logging(
        section=Character.UpdateSection.CONTACTS,
        method=character.update_contacts,
        force_update=force_update,
    )
    character.update_section_log_success(Character.UpdateSection.CONTACTS)


# special tasks for updating contracts


@shared_task(
    **{
        **TASK_DEFAULTS_BIND_ONCE,
        **{"once": {"keys": ["character_pk", "force_update"], "graceful": True}},
    }
)
def update_character_contracts(
    self,
    character_pk: int,
    force_update: bool,
    root_task_id: Optional[str] = None,
    parent_task_id: Optional[str] = None,
) -> None:
    """Update contracts of a character from ESI."""
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    section = Character.UpdateSection.CONTRACTS
    character.reset_update_section(
        section=section, root_task_id=root_task_id, parent_task_id=parent_task_id
    )
    logger.info("%s: Updating %s", character, section.label)
    priority = determine_task_priority(self) or MEMBERAUDIT_TASKS_LOW_PRIORITY
    chain(
        update_character_contract_headers.si(
            character.pk, force_update=force_update
        ).set(priority=priority),
        update_character_contracts_items.si(character.pk).set(priority=priority),
        update_character_contracts_bids.si(character.pk).set(priority=priority),
    ).delay()


@shared_task(**TASK_DEFAULTS_ONCE)
@when_esi_is_available
def update_character_contract_headers(character_pk: int, force_update: bool = False):
    """Update contract headers for a character from ESI."""
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    character.perform_update_with_error_logging(
        section=Character.UpdateSection.CONTRACTS,
        method=character.update_contract_headers,
        force_update=force_update,
    )


@shared_task(**TASK_DEFAULTS_BIND_ONCE)
def update_character_contracts_items(self, character_pk: int):
    """Update items for all contracts of a character from ESI."""
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    contract_pks = set(
        character.contracts.filter(
            contract_type__in=[
                CharacterContract.TYPE_ITEM_EXCHANGE,
                CharacterContract.TYPE_AUCTION,
            ],
            items__isnull=True,
        ).values_list("pk", flat=True)
    )
    if len(contract_pks) > 0:
        logger.info(
            "%s: Starting updating items for %s contracts", character, len(contract_pks)
        )
        priority = determine_task_priority(self) or MEMBERAUDIT_TASKS_LOW_PRIORITY
        for contract_pk in contract_pks:
            update_contract_items_esi.apply_async(
                kwargs={"character_pk": character.pk, "contract_pk": contract_pk},
                priority=priority,
            )

    else:
        logger.info("%s: No items to update", character)


@shared_task(**TASK_DEFAULTS_ONCE)
@when_esi_is_available
def update_contract_items_esi(character_pk: int, contract_pk: int):
    """Update the items of a character contract from ESI."""
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    contract = CharacterContract.objects.get(pk=contract_pk)
    character.update_contract_items(contract)


@shared_task(**TASK_DEFAULTS_BIND_ONCE)
def update_character_contracts_bids(self, character_pk: int):
    """Update bids for all contracts of a character from ESI."""
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    contract_pks = set(
        character.contracts.filter(
            contract_type__in=[CharacterContract.TYPE_AUCTION],
            status=CharacterContract.STATUS_OUTSTANDING,
        ).values_list("pk", flat=True)
    )
    if len(contract_pks) > 0:
        logger.info(
            "%s: Starting updating bids for %s contracts", character, len(contract_pks)
        )
        priority = determine_task_priority(self) or MEMBERAUDIT_TASKS_LOW_PRIORITY
        for contract_pk in contract_pks:
            update_contract_bids_esi.apply_async(
                kwargs={"character_pk": character.pk, "contract_pk": contract_pk},
                priority=priority,
            )

    else:
        logger.info("%s: No bids to update", character)
    character.update_section_log_success(Character.UpdateSection.CONTRACTS)


@shared_task(**TASK_DEFAULTS_ONCE)
@when_esi_is_available
def update_contract_bids_esi(character_pk: int, contract_pk: int):
    """Update bids of a character contract from ESI."""
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    contract = CharacterContract.objects.get(pk=contract_pk)
    character.update_contract_bids(contract)


# Tasks for other objects


@shared_task(**TASK_DEFAULTS_ONCE)
@when_esi_is_available
def update_market_prices():
    """Update market prices from ESI."""
    EveMarketPrice.objects.update_from_esi(
        minutes_until_stale=MEMBERAUDIT_UPDATE_STALE_RING_2
    )


@shared_task(
    **{
        **TASK_DEFAULTS_BIND_ONCE,
        **{
            "once": {"keys": ["id"], "graceful": True},
            "max_retries": MAX_RETRIES_STRUCTURES,
        },
    }
)
def update_structure_esi(self, id: int, token_pk: int):
    """Update a structure object from ESI.

    If the ESI error limit has already been reached retry later.
    """
    token = Token.objects.get(pk=token_pk)

    try:
        Location.objects.structure_update_or_create_esi(id, token)

    except EsiOffline as ex:
        backoff_jitter = int(random.uniform(2, 4) ** self.request.retries)
        countdown = (15 + backoff_jitter) * 60
        logger.warning(
            "Location #%s: ESI appears to be offline. Trying again in %d seconds.",
            id,
            countdown,
        )
        raise self.retry(countdown=countdown) from ex

    except EsiErrorLimitExceeded as ex:
        backoff_jitter = int(random.uniform(2, 4) ** self.request.retries)
        countdown = ex.retry_in + backoff_jitter
        logger.warning(
            "Location #%s: ESI error limit threshold reached. "
            "Trying again in %s seconds",
            id,
            countdown,
        )
        raise self.retry(countdown=countdown) from ex


@shared_task(
    **{
        **TASK_DEFAULTS_ONCE,
        **{
            "once": {"keys": ["id"], "graceful": True},
            "max_retries": MAX_RETRIES_MAIL_ENTITIES,
        },
    }
)
def update_mail_entity_esi(id: int, category: Optional[str] = None):
    """Update a mail entity object from ESI
    and retry later if the ESI error limit has already been reached.
    """
    MailEntity.objects.update_or_create_esi(id=id, category=category)


@shared_task(**TASK_DEFAULTS_BIND_ONCE)
def update_characters_skill_checks(self, force_update: bool = False) -> None:
    """Start the update of skill checks for all registered characters.

    Args:
        - force_update: When set to True will always update regardless of stale status
    """
    section = Character.UpdateSection.SKILL_SETS
    priority = determine_task_priority(self) or MEMBERAUDIT_TASKS_LOW_PRIORITY
    for character in Character.objects.all():
        if force_update or character.is_update_needed_for_section(section):
            update_character_section.apply_async(
                kwargs={
                    "character_pk": character.pk,
                    "section": section,
                    "force_update": force_update,
                },
                priority=priority,
            )


@shared_task(**TASK_DEFAULTS_ONCE)
def check_character_consistency(character_pk) -> None:
    """Check consistency of a character."""
    character: Character = Character.objects.get_cached(
        pk=character_pk, timeout=MEMBERAUDIT_TASKS_OBJECT_CACHE_TIMEOUT
    )
    character.update_sharing_consistency()


@shared_task(**TASK_DEFAULTS)
def delete_objects(model_name: str, obj_pks: Iterable[int]) -> None:
    """Delete multiple objects of a model."""
    model_class = apps.get_model("memberaudit", str(model_name))
    objs_to_delete = model_class.objects.filter(pk__in=obj_pks)
    amount = objs_to_delete.count()
    objs_to_delete.delete()
    logger.info("Deleted %d %s objects", amount, model_class.__name__)


@shared_task(**TASK_DEFAULTS_BIND)
def export_data(self, user_pk: Optional[int] = None) -> None:
    """Export data to files."""
    priority = determine_task_priority(self) or MEMBERAUDIT_TASKS_LOW_PRIORITY
    tasks = [
        _export_data_for_topic.si(topic).set(priority=priority)
        for topic in data_exporters.DataExporter.topics()
    ]
    if user_pk:
        tasks.append(_export_data_inform_user.si(user_pk))
    chain(tasks).delay()


@shared_task(**TASK_DEFAULTS_BIND)
def export_data_for_topic(self, topic: str, user_pk: int):
    """Export data for a topic."""
    priority = determine_task_priority(self) or MEMBERAUDIT_TASKS_LOW_PRIORITY
    chain(
        _export_data_for_topic.si(topic).set(priority=priority),
        _export_data_inform_user.si(user_pk, topic).set(priority=priority),
    ).delay()


@shared_task(**TASK_DEFAULTS_ONCE)
def _export_data_for_topic(topic: str, destination_folder: Optional[str] = None) -> str:
    """Export data for given topic into a zipped file in destination."""
    file_path = data_exporters.export_topic_to_archive(
        topic=topic, destination_folder=destination_folder
    )
    return str(file_path)


@shared_task(**TASK_DEFAULTS)
def _export_data_inform_user(user_pk: int, topic: Optional[str] = None):
    user = User.objects.get(pk=user_pk)
    if topic:
        title = f"{__title__}: Data export for {topic} completed"
        message = f"Data export has been completed for topic {topic}."
    else:
        title = f"{__title__}: Full data export completed"
        message = (
            "Data export for all topics has been completed. "
            "It covers the following:\n"
        )
        for obj in data_exporters.DataExporter.topics():
            message += f"- {obj}\n"  # pylint: disable=consider-using-join
    notify(user=user, title=title, message=message, level="INFO")


@shared_task(**TASK_DEFAULTS_BIND_ONCE)
def update_compliance_groups_for_all(self):
    """Update compliance groups for all users."""
    if ComplianceGroupDesignation.objects.exists():
        priority = determine_task_priority(self) or MEMBERAUDIT_TASKS_LOW_PRIORITY
        for user in User.objects.all():
            update_compliance_groups_for_user.apply_async(
                kwargs={"user_pk": user.pk}, priority=priority
            )


@shared_task(**TASK_DEFAULTS)
def update_compliance_groups_for_user(user_pk: int):
    """Update compliance groups for user."""
    user = User.objects.get(pk=user_pk)
    ComplianceGroupDesignation.objects.update_user(user)


@shared_task(**TASK_DEFAULTS)
def add_compliant_users_to_group(group_pk: int):
    """Add compliant users to given group."""
    group = Group.objects.get(pk=group_pk)
    General.add_compliant_users_to_group(group)


@shared_task(**TASK_DEFAULTS)
def clear_users_from_group(group_pk: int):
    """Clear all users from given group."""
    group = Group.objects.get(pk=group_pk)
    utils.clear_users_from_group(group)
