"""
El generador: recibir un pedido, ejecutarlo contra el proveedor y registrarlo.

Todo pasa por cola. La petición que llega de la pantalla deja la fila en
`QUEUED` y vuelve enseguida; la imagen la produce el trabajador, que es quien
espera al proveedor. El pedido v2 sale siempre de un análisis terminado: la
foto ya está en disco y lo detectado ya se sabe, así que acá sólo se validan
los contenedores, se congela el prompt y se encola. La landing pide por la
API con `request_website_generation`, que valida igual y cambia el prompt y el
tope por visitante; la API v1 ya no pide nada (contesta 410).
"""

import base64
import logging
import uuid
from contextlib import ExitStack
from pathlib import Path

from django.conf import settings
from django.contrib.staticfiles import finders
from django.db import transaction
from django.utils import timezone
from openai import APIConnectionError, APIStatusError, APITimeoutError, RateLimitError

from apps.ai import images, markup, prompt, provider, quota, watermark
from apps.ai.models import (
    AiSettings,
    GenerationError,
    GenerationOrigin,
    GenerationState,
    ImageGeneration,
    PhotoAnalysis,
)

logger = logging.getLogger(__name__)

#: Cuántas generaciones lista la vista de cola. El recorte se dice en pantalla.
QUEUE_LIMIT = 200
#: Cuántas recientes lista la pestaña del generador. Es una tabla con filtros,
#: no una grilla: el tope es el mismo tipo de recorte que el de la cola, dicho
#: en pantalla.
RECENT_LIMIT = 50
#: Tope de la foto de origen **a la entrada**. Es generoso porque lo que se
#: guarda no es lo que llega: la foto se reescala y se comprime antes de tocar
#: el disco, así que el tamaño original sólo cuesta el tiempo de subirla.
MAX_SOURCE_BYTES = 25 * 1024 * 1024
#: Tope de la capa de marcas. Es un PNG del tamaño de la foto ya normalizada
#: (1536 px de lado mayor) casi todo transparente, que pesa decenas de KB; el
#: tope sólo corta lo que no puede ser una capa del editor.
MAX_MARKUP_BYTES = 5 * 1024 * 1024

SOURCES_DIR = 'sources'
RESULTS_DIR = 'results'
MARKUPS_DIR = 'markups'


class TransientProviderError(Exception):
    """El proveedor falló de una forma que vale la pena reintentar."""

    def __init__(self, code: str):
        super().__init__(code)
        self.code = code


class ReferenceMissing(Exception):
    """
    Falta en disco una muestra que el catálogo declara y el prompt nombra.

    No tiene reintento ni camino alternativo: generar sin la muestra daría un
    color que nadie eligió, y el prompt seguiría diciendo que la imagen está.
    """

    code = GenerationError.REFERENCE_MISSING


class SourceImageInvalid(Exception):
    """La foto de origen no se pudo abrir como imagen."""

    code = 'IMAGE_INVALID'


class AnalysisNotReady(Exception):
    """
    Se pidió generar sobre un análisis que no lo admite (`PhotoAnalysis.can_generate`).

    Un análisis en curso todavía no sabe si hay pileta, y uno que falló por el
    pedido (el proveedor no contestó) se reintenta, no se usa. La pantalla no
    debería llegar acá —el botón de generar aparece sólo cuando se puede—, así
    que es una dirección armada a mano o una carrera.
    """

    code = 'ANALYSIS_NOT_READY'


def media_path(relative: str) -> Path:
    return Path(settings.AI_MEDIA_ROOT) / relative


def source_size(analysis: PhotoAnalysis) -> tuple[int, int]:
    """
    El ancho y el alto de la foto del análisis, ya normalizada.

    Es el tamaño exacto que tiene que tener la capa de marcas: `_clean_markup`
    lo exige y la landing lo recibe para dibujar sobre un lienzo de esa medida.
    Calcularlo en el navegador no es confiable —la foto se reescala y se gira
    según su EXIF al llegar—, así que hay una sola fuente: el archivo guardado.

    Levanta `images.NotAnImage` si la foto no está o no se puede abrir.
    """
    try:
        raw = media_path(analysis.source_path).read_bytes()
    except OSError as error:
        raise images.NotAnImage(str(error)) from error
    return images.size_of(raw)


def result_file(filename: str) -> Path | None:
    """
    El archivo de un resultado por su nombre, o nada si no es uno nuestro.

    El nombre lo escribimos nosotros —un UUID y la extensión— así que cualquier
    otra forma es un pedido armado a mano y se contesta como inexistente. Es lo
    que evita que la ruta pública sirva algo fuera de su carpeta.
    """
    return _owned_file(RESULTS_DIR, filename)


def source_file(filename: str) -> Path | None:
    """
    La foto de origen de un pedido, por su nombre, o nada si no es una nuestra.

    Misma regla que el resultado, en la otra carpeta. La sirve una ruta **con
    sesión**: es el patio real de un cliente, y sólo se mira desde la app.
    """
    return _owned_file(SOURCES_DIR, filename)


def markup_file(filename: str) -> Path | None:
    """Las marcas de un pedido, por su nombre. Misma regla, y también con sesión."""
    return _owned_file(MARKUPS_DIR, filename, 'png')


def _owned_file(folder: str, filename: str, expected: str = 'jpg') -> Path | None:
    stem, dot, extension = filename.partition('.')
    if dot != '.' or extension != expected:
        return None
    try:
        uuid.UUID(stem)
    except ValueError:
        return None
    path = media_path(f'{folder}/{filename}')
    return path if path.is_file() else None


def request_generation(
    user,
    analysis: PhotoAnalysis,
    areas,
    notes: str = '',
    guards=None,
    origin: str = GenerationOrigin.TOOLS_APP,
    markup_layer=None,
    markup_note=None,
) -> ImageGeneration:
    """
    Registra un pedido v2 sobre un análisis terminado y lo encola.

    Los contenedores se validan contra la taxonomía y la tabla área → servicios
    con lo que el análisis sabe de la pileta (`has_pool`), no contra lo que
    detectó: la persona puede agregar áreas a mano (R27). Lo que se guarda es la
    lista limpia que devuelve `clean_request`, ya en el orden de la taxonomía,
    y el prompt congelado que sale de ella.

    Las notas libres son opcionales y entran al prompt como un bloque propio,
    subordinado a las secciones y al cierre: agregan, no reemplazan.

    Las muestras que el catálogo declara como referencia se resuelven acá y se
    guardan con el pedido, en el mismo orden en que el prompt las nombra: el
    texto dice «Image 2» y esa numeración tiene que seguir valiendo cuando el
    trabajador abra los archivos, aunque el catálogo haya cambiado en el medio.

    La foto es la del análisis: no se vuelve a subir ni a comprimir. Se copia
    la ruta y no se lee por la relación porque el pedido tiene que poder
    explicarse solo, y porque `execute` sólo mira `source_path`. Siempre hay
    foto, así que siempre se edita sobre ella.

    Las marcas son opcionales: `markup_layer` es la capa de trazos subida, y
    con ella llega su nota (obligatoria), que dice qué se marcó y para qué. El
    código no interpreta los colores. El modo con que
    viajan se lee de la configuración **ahora** y se congela en el pedido,
    porque el prompt que se arma acá depende de él.
    """
    if not analysis.can_generate:
        raise AnalysisNotReady()

    cleaned = prompt.clean_request(areas, analysis.has_pool)
    cleaned_notes = prompt.clean_notes(notes)
    cleaned_guards = prompt.clean_guards(guards)
    references = prompt.references_of(cleaned)
    layer, markup_request = _clean_markup(analysis, markup_layer, markup_note)
    generation = _new_generation(
        analysis,
        cleaned,
        references,
        notes=cleaned_notes,
        guards=cleaned_guards,
        prompt_text=prompt.build_prompt(
            cleaned, analysis.has_pool, cleaned_notes, cleaned_guards, references, markup_request
        ),
        origin=origin,
        user=user,
    )
    _attach_markup(generation, layer, markup_request)
    return _enqueue(generation)


def request_website_generation(
    analysis: PhotoAnalysis, areas, note='', markup_layer=None, fingerprint: str = ''
) -> ImageGeneration:
    """
    Registra un pedido que llega de la landing por la API y lo encola.

    Valida igual que `request_generation` y cambia tres cosas:

    - **El prompt** es el de la landing (`prompt.build_website_prompt`): la nota
      del visitante manda sobre las secciones y un guard propio deja afuera lo
      que no son servicios de la compañía. Los guards de Tools no aplican y no
      se guardan.
    - **La nota es una sola.** Con marcas es su nota —y entonces obligatoria—;
      sin marcas va sola, como las notas libres de Tools, y puede faltar.
    - **El tope por visitante** se comprueba después de validar: un pedido mal
      armado no gasta cupo.

    Sin cuenta: lo pide un visitante, no quien es dueño de la clave.
    """
    if not analysis.can_generate:
        raise AnalysisNotReady()

    cleaned = prompt.clean_request(areas, analysis.has_pool)
    references = prompt.references_of(cleaned)
    if markup_layer is not None:
        layer, markup_request = _clean_markup(analysis, markup_layer, note)
        cleaned_note = ''
    else:
        layer, markup_request = None, None
        cleaned_note = prompt.clean_notes(note)
    quota.ensure_visitor_quota(fingerprint)

    generation = _new_generation(
        analysis,
        cleaned,
        references,
        notes=cleaned_note,
        guards={},
        prompt_text=prompt.build_website_prompt(
            cleaned, analysis.has_pool, cleaned_note, references, markup_request
        ),
        origin=GenerationOrigin.WEBSITE,
        user=None,
    )
    generation.visitor_fingerprint = fingerprint
    _attach_markup(generation, layer, markup_request)
    return _enqueue(generation)


def _new_generation(
    analysis: PhotoAnalysis,
    cleaned: list[prompt.AreaRequest],
    references: list[prompt.Reference],
    *,
    notes: str,
    guards: dict,
    prompt_text: str,
    origin: str,
    user,
) -> ImageGeneration:
    """La fila de un pedido v2, sin guardar: lo que tienen en común Tools y la landing."""
    return ImageGeneration(
        analysis=analysis,
        areas=[
            {
                'area': container.area,
                'service': container.service,
                'answers': container.answers,
                'whole_area': container.whole_area,
            }
            for container in cleaned
        ],
        kind='',
        notes=notes,
        guards=guards,
        references=[reference.image for reference in references],
        prompt=prompt_text,
        source_path=analysis.source_path,
        origin=origin,
        requested_by=user if getattr(user, 'pk', None) else None,
    )


def _attach_markup(
    generation: ImageGeneration, layer: bytes | None, markup_request: prompt.Markup | None
) -> None:
    if markup_request is None:
        return
    generation.markup_path = _store_markup(generation.id, layer)
    generation.markup_note = markup_request.note
    generation.markup_mode = markup_request.mode


def _clean_markup(
    analysis: PhotoAnalysis, layer, note
) -> tuple[bytes | None, prompt.Markup | None]:
    """
    La capa de marcas comprobada contra la foto, con su nota.

    Sin capa no hay marcas, y una nota suelta es un envío que no salió de la
    pantalla: se rechaza en vez de ignorarse, porque ignorarla haría creer a
    quien la mandó que llegó al modelo.
    """
    if layer is None:
        if note:
            raise prompt.AnswerInvalid(prompt.MARKUP_INVALID, 'markup')
        return None, None

    if getattr(layer, 'size', 0) > MAX_MARKUP_BYTES:
        raise prompt.AnswerInvalid(prompt.MARKUP_INVALID, 'markup')
    try:
        normalized = images.normalize_markup(_read_all(layer), source_size(analysis))
    except images.NotAnImage:
        raise prompt.AnswerInvalid(prompt.MARKUP_INVALID, 'markup') from None

    request = prompt.Markup(
        mode=AiSettings.current().markup_mode,
        note=prompt.clean_markup_note(note),
    )
    return normalized, request


def _store_markup(generation_id, layer: bytes) -> str:
    relative = f'{MARKUPS_DIR}/{generation_id}.png'
    path = media_path(relative)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_bytes(layer)
    return relative


def _enqueue(generation: ImageGeneration) -> ImageGeneration:
    """
    Guarda la fila y la encola, o la deja en `FAILED` si no hay proveedor.

    Sin clave del proveedor no se encola: se deja la fila en `FAILED` con su
    código, que es lo que la pantalla y la cola van a decir. Encolarla igual
    dejaría un pedido en espera de algo que no va a pasar.
    """
    from apps.ai import tasks

    if not provider.is_configured():
        _fail(generation, GenerationError.PROVIDER_NOT_CONFIGURED)
        logger.warning('IA: pedido %s rechazado, no hay OPENAI_API_KEY', generation.id)
        return generation

    generation.save()
    # Se encola al confirmar la transacción y no antes: si la fila no llegó a
    # escribirse, el trabajador no tiene que salir a buscarla.
    transaction.on_commit(lambda: tasks.generate_image.delay(str(generation.id)))
    return generation


def _read_all(source) -> bytes:
    """Un archivo subido, entero. Django lo entrega por trozos cuando es grande."""
    if hasattr(source, 'chunks'):
        return b''.join(source.chunks())
    return source.read()


def _store_source(generation_id, normalized: bytes) -> str:
    """Guarda la foto de origen ya comprimida. Sólo la lee la tarea."""
    relative = f'{SOURCES_DIR}/{generation_id}.jpg'
    path = media_path(relative)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_bytes(normalized)
    return relative


def execute(generation: ImageGeneration) -> ImageGeneration:
    """
    Produce la imagen de un pedido ya registrado.

    Es idempotente sobre un pedido terminado: la cola reconoce las tareas al
    terminarlas, así que una puede entregarse dos veces, y la segunda no tiene
    que pagar otra imagen.

    Lo que el proveedor contesta se clasifica en códigos cerrados. Los que vale
    la pena reintentar salen como `TransientProviderError`, con la fila devuelta
    a `QUEUED`, para que la tarea decida. El resto cierra `FAILED` acá mismo.
    """
    if generation.is_terminal:
        return generation

    generation.state = GenerationState.RUNNING
    generation.started_at = timezone.now()
    generation.save(update_fields=['state', 'started_at', 'updated_at'])

    # La configuración se lee **una vez por ejecución**, acá y no al pedir: lo
    # que se guarda en `usage` tiene que ser exactamente lo que se le mandó al
    # proveedor, y entre el pedido y la ejecución alguien pudo cambiarla.
    ai_settings = AiSettings.current()
    model = _model_for(generation, ai_settings)

    # El badge se busca antes de llamar: si faltara el archivo, el pedido no
    # paga una imagen que no se puede entregar con su marca. Un test comprueba
    # que estén los tres, así que esto no debería verse nunca.
    badge = watermark.badge_for(ai_settings.watermark, generation.id)
    try:
        badge_file = watermark.badge_path(badge)
    except watermark.BadgeMissing:
        logger.error('IA: falta el badge %s para el pedido %s', badge, generation.id)
        return _fail(generation, GenerationError.UNEXPECTED)

    try:
        response = _call_provider(generation, model, ai_settings.image_quality)
    except RateLimitError:
        _requeue(generation)
        raise TransientProviderError(GenerationError.PROVIDER_RATE_LIMIT) from None
    except (APITimeoutError, APIConnectionError):
        _requeue(generation)
        raise TransientProviderError(GenerationError.PROVIDER_UNAVAILABLE) from None
    except APIStatusError as error:
        if error.status_code >= 500:
            _requeue(generation)
            raise TransientProviderError(GenerationError.PROVIDER_UNAVAILABLE) from None
        logger.warning('IA: el proveedor rechazó %s: %s', generation.id, error)
        return _fail(generation, GenerationError.PROVIDER_REJECTED)
    except ReferenceMissing as error:
        # Sin reintento: el archivo no va a aparecer solo. Va con su propio
        # código para que la cola diga que falta una muestra y no «error
        # inesperado», que manda a buscar el problema del lado del proveedor.
        logger.error('IA: falta la muestra %s que pide el pedido %s', error, generation.id)
        return _fail(generation, GenerationError.REFERENCE_MISSING)
    except Exception:
        logger.exception('IA: fallo inesperado generando %s', generation.id)
        return _fail(generation, GenerationError.UNEXPECTED)

    encoded = _first_image(response)
    if not encoded:
        return _fail(generation, GenerationError.PROVIDER_EMPTY_RESPONSE)

    try:
        result = images.encode_result(base64.b64decode(encoded), badge_file)
    except (images.NotAnImage, ValueError):
        logger.warning('IA: el proveedor devolvió algo que no es una imagen para %s', generation.id)
        return _fail(generation, GenerationError.PROVIDER_EMPTY_RESPONSE)

    relative = f'{RESULTS_DIR}/{uuid.uuid4()}.jpg'
    path = media_path(relative)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_bytes(result)

    generation.result_path = relative
    # El modelo y la calidad van con el consumo: la 2.5 cobra por tokens y los
    # dos se eligen en «Config», así que un pedido sin esto no se puede costear
    # ni explicar después de que alguien cambie la configuración.
    generation.usage = {
        'model': model,
        'quality': ai_settings.image_quality,
        **_usage(response),
    }
    generation.state = GenerationState.COMPLETED
    generation.finished_at = timezone.now()
    generation.save(update_fields=['result_path', 'usage', 'state', 'finished_at', 'updated_at'])
    return generation


def _model_for(generation: ImageGeneration, ai_settings: AiSettings) -> str:
    """
    Con foto, el modelo de edición configurado; sin foto, el de generación.

    El segundo no es configurable: un pedido v2 siempre tiene foto, y el camino
    sin foto queda para las filas de la v1 y para el script de las imágenes de
    muestra.
    """
    return ai_settings.image_model if generation.source_path else provider.GENERATE_MODEL


def reference_path(relative: str) -> Path:
    """
    El archivo de una muestra del catálogo, en disco.

    Pasa por el buscador de estáticos y no por una ruta armada a mano: en
    desarrollo el archivo está en `static/` y en el servidor en `STATIC_ROOT`,
    y la ruta relativa es la misma que el catálogo publica.

    Que falte no es un caso a contemplar, es un error nuestro: el catálogo la
    declara, el pedido ya la prometió por número en el prompt y generar sin ella
    daría un color que nadie eligió. Un test recorre el catálogo y comprueba que
    estén todas, así que esto no debería verse nunca.
    """
    found = finders.find(relative)
    if found is None:
        raise ReferenceMissing(relative)
    return Path(found)


def _call_provider(generation: ImageGeneration, model: str, quality: str):
    """
    Con foto se edita sobre ella; sin foto se crea desde el texto, como en la
    landing. Cada camino tiene su modelo: el de edición cuida lo que ya está en
    la foto, el otro sólo tiene que dibujar bien.

    Las muestras van en la misma lista que la foto y **después** de ella: el
    proveedor las recibe sin rótulo, así que la posición es lo único que las
    identifica y el prompt las nombra por ese número.

    Las marcas, si las hay, las resuelve `markup.provider_inputs` con el modo
    congelado en el pedido: el dibujo va al final de la lista, detrás de las
    muestras.
    """
    api = provider.client()
    if generation.source_path:
        with ExitStack() as stack:
            images_sent = [stack.enter_context(media_path(generation.source_path).open('rb'))]
            images_sent.extend(
                stack.enter_context(reference_path(relative).open('rb'))
                for relative in generation.references
            )
            if generation.markup_path:
                inputs = markup.provider_inputs(
                    generation.markup_mode,
                    media_path(generation.source_path).read_bytes(),
                    media_path(generation.markup_path).read_bytes(),
                )
                images_sent.extend(inputs.extra_images)
            return api.images.edit(
                model=model,
                image=images_sent if len(images_sent) > 1 else images_sent[0],
                prompt=generation.prompt,
                quality=quality,
                n=1,
            )
    return api.images.generate(
        model=model,
        prompt=generation.prompt,
        quality=quality,
        n=1,
    )


def _first_image(response) -> str | None:
    data = getattr(response, 'data', None) or []
    if not data:
        return None
    return getattr(data[0], 'b64_json', None)


def _usage(response) -> dict:
    usage = getattr(response, 'usage', None)
    if usage is None:
        return {}
    if hasattr(usage, 'model_dump'):
        return usage.model_dump()
    return dict(usage) if isinstance(usage, dict) else {}


def _requeue(generation: ImageGeneration) -> None:
    generation.state = GenerationState.QUEUED
    generation.save(update_fields=['state', 'updated_at'])


def _fail(generation: ImageGeneration, code: str) -> ImageGeneration:
    generation.state = GenerationState.FAILED
    generation.error_code = code
    generation.finished_at = timezone.now()
    generation.save()
    return generation


def fail(generation: ImageGeneration, code: str) -> ImageGeneration:
    """Cierra un pedido que la tarea dejó de reintentar."""
    return _fail(generation, code)


def generation_props(generation: ImageGeneration, with_prompt: bool = True) -> dict:
    """
    La forma en que un pedido viaja a la pantalla y a la cola.

    Van las dos formas —`kind` y `answers` de la v1, `analysis_id` y `areas`
    de la v2— y `is_v2` dice cuál leer. Una fila vieja viaja con sus datos
    aunque la pantalla ya no tenga catálogo con qué resumirlos (decisión 1):
    lo que se guardó se sigue mostrando, no se recorta al cambiar de versión.

    `with_prompt` en falso deja el prompt vacío en vez de mandarlo: es lo que
    ve un rol que no puede leerlo (R53). Se decide acá y no en la pantalla
    porque esconder un texto que igual viajó en las props no es esconderlo.
    """
    return {
        'id': str(generation.id),
        'kind': generation.kind,
        'origin': generation.origin,
        'state': generation.state,
        'error_code': generation.error_code or None,
        'is_v2': generation.is_v2,
        'analysis_id': str(generation.analysis_id) if generation.analysis_id else None,
        # Los contenedores viajan tal cual se guardaron: son lo que la pantalla
        # muestra mientras se genera y lo que precarga para ajustar y volver a
        # pedir.
        'areas': generation.areas,
        'answers': generation.answers,
        # Las notas tal como se escribieron, para mostrarlas en el resumen y
        # para devolverlas al formulario al ajustar el pedido.
        'notes': generation.notes,
        # Lo que se bajó, para el resumen y para volver al formulario como
        # estaba. Un pedido viejo no lo tiene: la pantalla los da por levantados.
        'guards': generation.guards,
        # El prompt exacto que se mandó, para verlo en el pedido. Sólo en la
        # pantalla, y sólo para quien puede leerlo: la API no lo expone.
        'prompt': generation.prompt if with_prompt else '',
        'requested_by': generation.requested_by.email if generation.requested_by else None,
        'has_source_image': generation.has_source_image,
        'result_url': generation.result_url,
        # Sólo en la pantalla: la ruta pide sesión y la API no la expone.
        'source_url': (
            f'{settings.AI_MEDIA_URL}{SOURCES_DIR}/{generation.source_path.rsplit("/", 1)[-1]}'
            if generation.source_path
            else None
        ),
        # Las marcas como se pidieron, para el resumen y para devolverlas al
        # editor al ajustar. Con sesión, igual que la foto.
        'markup_url': (
            f'{settings.AI_MEDIA_URL}{generation.markup_path}' if generation.markup_path else None
        ),
        'markup_note': generation.markup_note,
        'markup_mode': generation.markup_mode or None,
        'created_at': generation.created_at.isoformat(),
        'started_at': generation.started_at.isoformat() if generation.started_at else None,
        'finished_at': generation.finished_at.isoformat() if generation.finished_at else None,
        'duration_seconds': generation.duration_seconds,
    }


def recent(with_prompt: bool = True) -> tuple[list[dict], int]:
    """
    Lo último pedido, en cualquier estado, recortado y con su total.

    Van también las fallidas y las que corren: la pestaña de recientes es el
    registro del generador, y un registro que esconde lo que salió mal hace
    creer que todo salió bien. Ya no se filtra por tipo: hay un solo generador
    y las filas v1 se listan junto a las v2.
    """
    everything = ImageGeneration.objects.select_related('requested_by')
    rows = [generation_props(g, with_prompt) for g in everything[:RECENT_LIMIT]]
    return rows, ImageGeneration.objects.count()


def queue_rows(with_prompt: bool = True) -> tuple[list[dict], int]:
    """Lo último que se pidió, de cualquier versión y origen, recortado y con su total."""
    everything = ImageGeneration.objects.select_related('requested_by')
    rows = [generation_props(g, with_prompt) for g in everything[:QUEUE_LIMIT]]
    return rows, ImageGeneration.objects.count()


def active_states() -> list[str]:
    """
    Los estados no terminales que hay ahora. De acá sale el sondeo de la cola y
    de la pestaña de recientes.
    """
    pending = ImageGeneration.objects.filter(
        state__in=[GenerationState.QUEUED, GenerationState.RUNNING]
    )
    return list(pending.values_list('state', flat=True))
