"""Procrastinate-backed ingest queue — the Postgres ``TaskProducer`` + worker
(Deck #183).

This replaces NATS JetStream and the old Postgres-queue stub. The MCP server now
owns *both* sides of ingest:

- **Producer** (API role / scanner) — :class:`ProcrastinateTaskProducer.send`
  *defers* one ``ingest:process_document`` job per changed document onto the
  cheapest tier's queue (``ingest-fast``) in the per-tenant Postgres (the same
  app DB; procrastinate manages its own tables).
- **Consumer** (worker role) — ``nextcloud-mcp-server worker [--tier T]`` runs
  :func:`procrastinate.App.run_worker`, which drains its tier's queue and invokes
  the existing :func:`process_document` pipeline. A parse too poor to index hops
  the job to the next tier's queue (see :class:`TieredEscalationStrategy`), so
  cheap CPU parsing and paid OCR run on independently-scaled fleets (Deck #323).

Design notes:

- **No execution ``lock``, only ``queueing_lock``.** procrastinate does NOT
  auto-reclaim ``doing`` jobs, so a per-doc execution lock would permanently
  deadlock a document if a worker crashed mid-job. The Qdrant upsert is
  idempotent (deterministic ``uuid5`` point IDs), so a concurrent/re-run is
  harmless; ``queueing_lock`` (partial-unique on ``status='todo'``) is enough to
  dedupe enqueues, and :func:`reclaim_stalled_ingest_jobs` retries jobs orphaned
  in ``doing`` by a crash.
- procrastinate is Postgres-only and uses asyncio; ``anyio`` runs natively on the
  asyncio backend, so the worker can call the anyio-based pipeline directly.
- Tasks are defined on a :class:`procrastinate.Blueprint` so the connector is
  decoupled from the task registry: production binds a real
  :class:`PsycopgConnector`; unit tests bind ``testing.InMemoryConnector``.
"""

from __future__ import annotations

import logging
from dataclasses import asdict
from datetime import datetime, timedelta, timezone
from types import TracebackType
from typing import TYPE_CHECKING

from procrastinate import (
    App,
    BaseRetryStrategy,
    Blueprint,
    JobContext,
    PsycopgConnector,
    RetryDecision,
)
from procrastinate.connector import BaseConnector
from procrastinate.exceptions import AlreadyEnqueued
from procrastinate.jobs import Job

from ...config import get_procrastinate_conninfo, get_settings
from ..scanner import DocumentTask

if TYPE_CHECKING:
    from ...client import NextcloudClient

logger = logging.getLogger(__name__)

# One queue per extraction tier (Deck #323), aligned cheapest-first with
# document_processors.escalation.TIER_LADDER. Each queue is drained by its own
# worker Deployment + KEDA ScaledObject (``SELECT count(*) FROM
# procrastinate_jobs WHERE queue_name=<queue> AND status='todo'``), so a
# CPU-bound ``fast`` fleet, an in-cluster ``structured`` fleet, and a paid
# network-bound ``ocr`` fleet scale (and fail) independently.
INGEST_QUEUE_FAST = "ingest-fast"
INGEST_QUEUE_STRUCTURED = "ingest-structured"
INGEST_QUEUE_OCR = "ingest-ocr"

# tier -> queue. The producer always defers onto the cheapest tier's queue; a
# low-quality parse hops the job up the ladder via the retry strategy below.
TIER_QUEUES: dict[str, str] = {
    "fast": INGEST_QUEUE_FAST,
    "structured": INGEST_QUEUE_STRUCTURED,
    "ocr": INGEST_QUEUE_OCR,
}
_QUEUE_TIERS: dict[str, str] = {queue: tier for tier, queue in TIER_QUEUES.items()}
ALL_INGEST_QUEUES: tuple[str, ...] = tuple(TIER_QUEUES.values())
# New jobs start here; ``ocr`` is reached only by escalation.
DEFAULT_INGEST_QUEUE = INGEST_QUEUE_FAST

# Legacy single-queue name (pre-#323). A rolling upgrade may still have jobs
# parked on it; a worker can be told to drain it alongside the tier queues, and
# the job-count / reclaim helpers include it so nothing is stranded.
LEGACY_INGEST_QUEUE = "ingest"
# Back-compat alias for callers that imported the old single-queue constant.
INGEST_QUEUE_NAME = DEFAULT_INGEST_QUEUE

# Maintenance queue carrying ONLY the periodic stalled-job reclaim (no document
# jobs). Every worker drains it regardless of --tier, so the reclaim fires even
# in an asymmetric deployment where the fast fleet is scaled to zero and only
# ocr workers run. procrastinate's periodic-defer dedup ensures exactly one
# worker runs each tick even when many drain this queue. Kept off document
# queues so tier isolation (which fleet processes which docs) is preserved.
INGEST_QUEUE_MAINTENANCE = "ingest-maintenance"

# Queues the job-count + reclaim helpers sweep (tier queues + the legacy one).
_MANAGED_QUEUES: tuple[str, ...] = (*ALL_INGEST_QUEUES, LEGACY_INGEST_QUEUE)

# Blueprint namespace → registered task names are prefixed ``ingest:``.
_NAMESPACE = "ingest"
INGEST_TASK_NAME = f"{_NAMESPACE}:process_document"


def tier_for_queue(queue: str | None) -> str:
    """Tier a worker on ``queue`` should run. Unknown/legacy -> ``fast``.

    The queue-aware task uses this to pick which single tier to parse with: the
    job's current queue *is* its tier. A job on the legacy ``ingest`` queue (or
    any unrecognised queue) defaults to the cheapest tier.
    """
    return _QUEUE_TIERS.get(queue or "", "fast")


# A crashed worker leaves its job in ``doing``; reclaim it once its (per-worker)
# heartbeat is this many seconds stale. The default is sized well above the
# longest expected ``process_document`` (PDF render + embedding) so a slow-but-
# live worker — whose heartbeat stays current during a long job — is never
# reclaimed out from under itself. Operators on slow embedding backends can tune
# it via INGEST_STALLED_JOB_SECONDS (read per-run in reclaim_stalled_ingest_jobs).


# Tasks are defined as plain functions and registered onto a *fresh* Blueprint
# per app (see _build_ingest_blueprint). procrastinate's add_tasks_from mutates
# the blueprint's task names in place (namespace prefixing), so a single shared
# Blueprint cannot be added to more than one App — which the tests (in-memory +
# real Postgres) and any re-init path require.
async def process_document_task(
    context: JobContext,
    *,
    user_id: str,
    doc_id: str,
    doc_type: str,
    operation: str,
    modified_at: int,
    file_path: str | None = None,
    metadata: dict[str, int | str] | None = None,
    etag: str | None = None,
    owner_id: str | None = None,
) -> None:
    """Worker entry: rebuild the DocumentTask, resolve creds, run the pipeline.

    Queue-aware (Deck #323): the tier this worker runs is the tier of the job's
    current queue. A low-quality parse raises ``EscalateError``, which the
    :class:`TieredEscalationStrategy` turns into a queue-hop to the next tier.
    When per-tier escalation is disabled (``INGEST_ESCALATION_ENABLED=false``),
    ``tier`` stays ``None`` and the inline pipeline runs (fast -> OCR in one
    call), reproducing the pre-#323 single-queue behaviour.
    """
    # Local imports avoid a heavy import chain at blueprint-definition time
    # (this module is also imported by the API pod just to defer jobs).
    from ..oauth_sync import NotProvisionedError  # noqa: PLC0415
    from ..processor import process_document  # noqa: PLC0415

    tier = (
        tier_for_queue(context.job.queue)
        if get_settings().ingest_escalation_enabled
        else None
    )

    task = DocumentTask(
        user_id=user_id,
        doc_id=doc_id,
        doc_type=doc_type,
        operation=operation,
        modified_at=modified_at,
        file_path=file_path,
        metadata=metadata,
        etag=etag,
        owner_id=owner_id,
    )
    try:
        nc_client = await _resolve_client(user_id)
    except NotProvisionedError:
        # A deprovisioned user must not pin a worker slot retrying forever.
        # Finish the job as a no-op; the next scan re-enqueues once the user
        # re-provisions an app password. Other errors (transient DB/network)
        # propagate so procrastinate's retry strategy handles them.
        logger.warning(
            "ingest.skip_no_credentials user=%s doc=%s:%s", user_id, doc_type, doc_id
        )
        return

    try:
        # Durable retry is procrastinate's job; disable the in-process loop.
        await process_document(task, nc_client, max_retries=1, tier=tier)
    finally:
        await nc_client.close()


async def reclaim_stalled_ingest_jobs(context: JobContext, timestamp: int) -> None:
    """Re-queue ingest jobs orphaned in ``doing`` by a crashed worker.

    procrastinate prunes dead *workers* but does not reset their in-flight jobs;
    without this they'd sit in ``doing`` forever. ``timestamp`` is procrastinate's
    periodic-run marker (unused).
    """
    manager = context.app.job_manager
    settings = get_settings()
    # Stagger the re-run rather than retry at now(): a stall is often systemic (a
    # Qdrant / embedding outage stalls every in-flight job), so reclaiming the
    # whole batch immediately every tick would thundering-herd a recovering
    # dependency, bypassing TieredEscalationStrategy's per-job backoff. The fixed
    # delay spreads them out; 0 restores the legacy immediate retry.
    retry_at = datetime.now(tz=timezone.utc) + timedelta(
        seconds=settings.ingest_reclaim_retry_delay_seconds
    )
    stalled_after = settings.ingest_stalled_job_seconds
    reclaimed = 0
    # queue=None sweeps every queue, so an orphaned job on any tier queue is
    # reclaimed regardless of which tier's worker happens to run this periodic.
    # retry_job_by_id_async keeps the job on its own queue, so a stalled ``ocr``
    # job re-runs on ``ingest-ocr`` (the ocr fleet), not the reclaiming worker's.
    for job in await manager.get_stalled_jobs(seconds_since_heartbeat=stalled_after):
        if job.id is None:
            continue
        await manager.retry_job_by_id_async(job_id=job.id, retry_at=retry_at)
        reclaimed += 1
    if reclaimed:
        logger.warning("ingest.reclaimed_stalled_jobs count=%d", reclaimed)
    else:
        # Visible heartbeat under verbose logging when debugging a suspected
        # reclaim failure, without noising up production logs.
        logger.debug("ingest.reclaim_check stalled=0")


async def _resolve_client(user_id: str) -> NextcloudClient:
    """Build an authenticated NextcloudClient for ``user_id`` in the worker.

    Single-user BasicAuth uses the shared env credentials; every multi-user mode
    resolves the user's locally-stored app password (BasicAuth).
    """
    from ...client import NextcloudClient  # noqa: PLC0415
    from ...config_validators import AuthMode, detect_auth_mode  # noqa: PLC0415

    settings = get_settings()
    if detect_auth_mode(settings) == AuthMode.SINGLE_USER_BASIC:
        return NextcloudClient.from_env()

    from ..oauth_sync import get_user_client_basic_auth  # noqa: PLC0415

    host = settings.nextcloud_host
    if not host:
        raise ValueError("NEXTCLOUD_HOST is required for multi-user ingest")
    return await get_user_client_basic_auth(user_id, host)


def _first_leaf(exc: BaseException) -> BaseException:
    """Descend nested ExceptionGroups to the first concrete leaf exception.

    An anyio task group can wrap the real cause (and nest groups); the retry
    strategy classifies on the leaf, mirroring ``processor._drop_reason``.
    """
    while isinstance(exc, BaseExceptionGroup) and exc.exceptions:
        exc = exc.exceptions[0]
    return exc


def _is_transient_infra_error(exc: BaseException) -> bool:
    """Whether ``exc`` is a transient infra blip worth a SAME-tier retry.

    Mirrors the retryable subset of ``processor._drop_reason``: doc-fetch /
    embed / Qdrant timeouts, connection drops, rate limits, and 5xx. A parse
    that is merely *poor* never reaches here -- that path raises
    ``EscalateError`` (handled separately) -- so this is purely about
    infrastructure that should recover on its own. Imports are lazy: this only
    runs in the worker, and the module is also imported by the API pod to defer.
    """
    import httpx  # noqa: PLC0415

    if isinstance(exc, (httpx.TimeoutException, httpx.ConnectError)):
        return True
    try:
        import openai  # noqa: PLC0415

        if isinstance(
            exc,
            (
                openai.APITimeoutError,
                openai.APIConnectionError,
                openai.RateLimitError,
            ),
        ):
            return True
        if isinstance(exc, openai.APIStatusError):
            return exc.status_code >= 500
    except ImportError:  # pragma: no cover -- openai is a hard dependency
        pass
    # Deliberately over-broad: this treats ALL qdrant_client exceptions as
    # transient (not just timeouts/5xx). In a healthy cluster qdrant errors are
    # transient, and a bounded same-tier retry is cheap; a genuinely permanent
    # qdrant fault (e.g. schema mismatch) just exhausts the transient cap and
    # then gives up. So unlike _drop_reason (which only *labels* the cause), this
    # may add a few retries on a non-retriable qdrant error -- an acceptable
    # trade for not having to enumerate qdrant's non-retriable status codes.
    if type(exc).__module__.startswith("qdrant_client"):
        return True
    return False


class TieredEscalationStrategy(BaseRetryStrategy):
    """Native procrastinate retry that escalates across tier queues (Deck #323).

    Three outcomes, decided from the raised exception:

    - ``EscalateError`` -> ``RetryDecision(queue=<next tier's queue>)``: the SAME
      job hops to the next fleet's queue and is parsed once by that tier. This is
      how a document is "requeued on a failed parse" -- once per tier, with no
      same-tier parse retry.
    - a whitelisted transient infra error (doc fetch / embed / Qdrant blip) ->
      same-queue exponential backoff, while under ``max_transient_attempts``.
    - anything else, the transient cap is reached, or the target tier is unknown
      -> ``None`` (no retry); the placeholder was already marked failed by the
      pipeline, and the next scan re-picks the document.

    Per-tier attempt accounting is intentionally approximate: a queue-hop can't
    reset ``job.attempts`` (procrastinate has no per-tier counter), so parse
    escalations *do* advance the same counter the transient cap reads. Because a
    parse escalation hops (it never retries in place) the "once per parse per
    tier" guarantee is structural; the cap is just a generous global ceiling on
    transient churn across the whole lineage, not an exact per-tier count.
    """

    def __init__(self, *, max_transient_attempts: int) -> None:
        self._max_transient_attempts = max_transient_attempts

    def get_retry_decision(
        self, *, exception: BaseException, job: Job
    ) -> RetryDecision | None:
        # Lazy import: these live in the document stack, which the API pod (it
        # also builds this App to defer) must not load. get_retry_decision runs
        # only in the worker, where the stack is already imported.
        from ...document_processors.escalation import (  # noqa: PLC0415
            BatchPending,
            EscalateError,
        )

        exc = _first_leaf(exception)

        if isinstance(exc, BatchPending):
            # Batch OCR job still in flight (Deck #332): defer a re-poll on the
            # SAME queue after retry_in seconds. Deliberately exempt from the
            # transient cap below — a batch job can take minutes-hours, so the
            # poll count is unbounded here; the OCR processor's own deadline
            # (DOCUMENT_OCR_BATCH_MAX_WAIT_SECONDS) is what terminates a stuck job.
            # Releasing the worker between polls keeps the job out of `doing`, so
            # it's never stall-reclaimed.
            return RetryDecision(retry_in={"seconds": exc.retry_in})

        if isinstance(exc, EscalateError):
            queue = TIER_QUEUES.get(exc.to_tier)
            if queue is None:
                # Unknown target tier: don't strand the job on a queue no worker
                # drains -- stop and let the placeholder/next scan handle it.
                logger.error(
                    "ingest.escalate_unknown_tier from=%s to=%s",
                    exc.from_tier,
                    exc.to_tier,
                )
                return None
            logger.info(
                "ingest.escalate from=%s to=%s reason=%s queue=%s",
                exc.from_tier,
                exc.to_tier,
                exc.reason,
                queue,
            )
            # Immediate hop -- the next tier's fleet should pick it up at once.
            return RetryDecision(queue=queue, retry_in={"seconds": 0})

        if (
            _is_transient_infra_error(exc)
            and job.attempts < self._max_transient_attempts
        ):
            # 4, 8, 16, ... seconds, capped at 5 min. attempts is >=1 here (the
            # failing attempt is counted), so attempts-1 makes the first wait 4s.
            wait = min(4 * (2 ** max(0, job.attempts - 1)), 300)
            return RetryDecision(retry_in={"seconds": wait})

        return None


def _build_ingest_blueprint() -> Blueprint:
    """Create a fresh Blueprint with the ingest tasks registered.

    Fresh per call because ``add_tasks_from`` mutates the blueprint's task names
    (namespace prefixing), so the same Blueprint cannot be reused across Apps.
    """
    bp = Blueprint()
    # Durable retry owned by the queue (survives worker crashes); the in-process
    # retry loop in process_document is disabled on this path via max_retries=1.
    # The task's default queue is the cheapest tier; the producer defers there
    # explicitly and the strategy hops a job up the ladder on a poor parse.
    bp.task(  # type: ignore[no-matching-overload]
        name="process_document",
        queue=DEFAULT_INGEST_QUEUE,
        pass_context=True,
        # procrastinate's RetryValue type only admits RetryStrategy, but a custom
        # BaseRetryStrategy subclass is the documented extension point (and is
        # accepted at runtime by get_retry_strategy). The annotation is just too
        # narrow, hence the ignore.
        # Settings are snapshotted here at blueprint-build time (build_app, first
        # use), so a restart is needed to pick up INGEST_TRANSIENT_MAX_ATTEMPTS
        # changes -- intentional: the strategy lives for the App's lifetime.
        retry=TieredEscalationStrategy(
            max_transient_attempts=get_settings().ingest_transient_max_attempts
        ),
    )(process_document_task)
    # Reclaim runs on the dedicated maintenance queue (every worker drains it),
    # not a tier queue -- otherwise an ocr-only deployment (fast scaled to zero)
    # would never fire the periodic and orphaned ``doing`` jobs would never be
    # reclaimed. The task itself sweeps ALL queues (get_stalled_jobs(queue=None)).
    reclaim = bp.task(
        name="reclaim_stalled_jobs", queue=INGEST_QUEUE_MAINTENANCE, pass_context=True
    )(reclaim_stalled_ingest_jobs)
    bp.periodic(cron="*/5 * * * *", periodic_id="reclaim_stalled_ingest")(reclaim)
    return bp


def build_app(connector: BaseConnector) -> App:
    """Build an App for the given connector with the ingest tasks registered.

    Shared by production (:func:`get_procrastinate_app`) and tests (which pass a
    ``testing.InMemoryConnector``).
    """
    app = App(connector=connector)
    app.add_tasks_from(_build_ingest_blueprint(), namespace=_NAMESPACE)
    return app


def build_app_for_url(database_url: str) -> App:
    """Build an App bound to an explicit Postgres URL (for the CLI, which may
    target a ``--database-url`` that differs from the ``DATABASE_URL`` env)."""
    return build_app(
        PsycopgConnector(conninfo=get_procrastinate_conninfo(database_url))
    )


_app: App | None = None


def get_procrastinate_app() -> App:
    """Process-wide procrastinate App bound to the Postgres app database."""
    global _app
    if _app is None:
        _app = build_app(PsycopgConnector(conninfo=get_procrastinate_conninfo()))
    return _app


async def _ingest_schema_present(app: App) -> bool:
    row = await app.connector.execute_query_one_async(
        "SELECT to_regclass('procrastinate_jobs') IS NOT NULL AS present"
    )
    return bool(row["present"])


async def _apply_ingest_queue_schema_open(app: App) -> None:
    """Apply the ingest-queue schema on an already-open connector (apply-if-absent).

    procrastinate's ``schema.sql`` uses bare ``CREATE TYPE``/``CREATE TABLE``
    (not ``IF NOT EXISTS``), so it errors if re-applied — it is meant to run
    once on a fresh DB. We skip when ``procrastinate_jobs`` already exists;
    *version* upgrades use procrastinate's own migration files (operator-run, a
    lineage independent of the app's Alembic schema).

    Safe to call concurrently across rolling-update pods without an advisory
    lock: Postgres DDL is transactional and procrastinate applies the whole
    schema in one transaction, so a pod that loses the race rolls back cleanly
    and we treat the resulting error as benign once the schema is present.
    """
    if await _ingest_schema_present(app):
        logger.debug("ingest queue schema already present; skipping apply")
        return
    try:
        await app.schema_manager.apply_schema_async()
        logger.info("Applied procrastinate ingest queue schema")
    except Exception:
        # The apply runs in a single transaction, so any failure rolls back
        # atomically (no partial schema). The only benign case is losing the
        # create race to another pod — confirmed by re-checking presence. Any
        # other failure (network, auth, …) leaves the schema absent, so this
        # branch re-raises it rather than masking it.
        if await _ingest_schema_present(app):
            logger.info("Ingest queue schema applied concurrently by another pod")
            return
        raise


async def apply_ingest_queue_schema(
    app: App | None = None, *, manage_connection: bool = True
) -> None:
    """Create procrastinate's tables on a fresh database (apply-if-absent).

    By default opens a short-lived connection, so it is safe to call standalone
    from the CLI ``db upgrade`` path. Pass ``manage_connection=False`` when the
    caller already holds an open connector (the ``worker`` command opens the App
    once and reuses it) to avoid a redundant open/close cycle.
    """
    app = app or get_procrastinate_app()
    if not manage_connection:
        await _apply_ingest_queue_schema_open(app)
        return
    async with app.open_async():
        await _apply_ingest_queue_schema_open(app)


# Job-status keys procrastinate flattens into each list_queues row (alongside
# ``name`` and ``jobs_count``). ``aborting`` is legacy/unused since v3.0.0.
_JOB_STATUSES = ("todo", "doing", "succeeded", "failed", "cancelled", "aborted")


async def get_ingest_job_counts_by_queue(
    app: App | None = None,
) -> dict[str, dict[str, int]]:
    """Per-queue ingest job counts by status (Deck #323).

    Returns ``{queue_name: {status: count}}`` for the managed ingest queues (the
    per-tier queues + the legacy single queue) that have rows. Reads
    procrastinate's per-queue stats via the manager API (not hand-written SQL) so
    a future schema bump doesn't silently break the status surface. Assumes the
    app's connector is already open. Feeds the per-tier status surface + the
    ``astrolabe_ingest_queue_depth`` gauge.
    """
    app = app or get_procrastinate_app()
    by_queue: dict[str, dict[str, int]] = {}
    for row in await app.job_manager.list_queues_async():
        name = row.get("name")
        if name not in _MANAGED_QUEUES:
            continue
        per = by_queue.setdefault(name, {})
        for status in _JOB_STATUSES:
            if status in row:
                per[status] = per.get(status, 0) + int(row[status])
    return by_queue


async def get_ingest_job_counts(app: App | None = None) -> dict[str, int]:
    """Aggregate ingest job counts by status across all managed queues.

    Fleet-wide totals summed over the per-tier queues + the legacy queue, so
    ``pending = todo + doing`` reflects all outstanding ingest work regardless of
    which tier a document currently sits on. Per-queue breakdown:
    :func:`get_ingest_job_counts_by_queue`.
    """
    counts: dict[str, int] = {}
    for per in (await get_ingest_job_counts_by_queue(app)).values():
        for status, value in per.items():
            counts[status] = counts.get(status, 0) + value
    return counts


def _doc_queueing_lock(task: DocumentTask) -> str:
    """Per-document enqueue-dedup key (partial-unique on ``status='todo'``).

    Collision-safe with a raw ``:`` delimiter because the first two segments can
    never themselves contain ``:``:

    - ``user_id`` — a Nextcloud username/UID; Nextcloud rejects ``:`` in
      usernames (Web UI + provisioning validation), so it is colon-free.
    - ``doc_type`` — a controlled enum (``note``/``file``/``deck_card``/
      ``news_item``); a future ``doc_type`` containing ``:`` would break this
      invariant, so keep doc_type colon-free.

    The trailing ``doc_id`` may contain anything — it's the final unambiguous
    segment.
    """
    return f"{task.user_id}:{task.doc_type}:{task.doc_id}"


class ProcrastinateTaskProducer:
    """``TaskProducer`` that defers ingest jobs into Postgres via procrastinate.

    The App's connector pool is owned by the server lifespan (opened once,
    closed on shutdown), so ``clone``/``aenter``/``aexit``/``aclose`` are no-ops
    — there is no per-handle resource like the memory stream's clones.
    """

    def __init__(self, app: App):
        self._app = app

    @classmethod
    async def connect(cls) -> ProcrastinateTaskProducer:
        app = get_procrastinate_app()
        # ``App.open_async()`` returns procrastinate's dual-mode AwaitableContext:
        # ``await``-ing it opens the connector pool and leaves it open (vs the
        # ``async with`` form, which closes on block exit). The producer's pool is
        # long-lived — owned by the server lifespan and torn down once in
        # ``drain()`` (close_async) on shutdown — so the bare ``await`` is correct
        # here, unlike the scoped ``async with`` used for one-shot schema apply.
        await app.open_async()
        return cls(app)

    async def send(self, task: DocumentTask, /) -> None:
        key = _doc_queueing_lock(task)
        # Always defer onto the cheapest tier's queue; the escalation strategy
        # hops the job up the ladder on a poor parse. queueing_lock is a global
        # partial-unique on status='todo', so a doc mid-escalation on a higher
        # tier still dedupes a fresh enqueue here -- no double-processing.
        deferrer = self._app.configure_task(
            INGEST_TASK_NAME, queue=DEFAULT_INGEST_QUEUE, queueing_lock=key
        )
        try:
            await deferrer.defer_async(**asdict(task))
        except AlreadyEnqueued:
            # A todo job already exists for this doc; the next periodic scan
            # re-evaluates freshness (placeholder/Qdrant modified_at only
            # advances after a successful index), so this is not a lost update.
            logger.debug("ingest.already_enqueued key=%s", key)

    async def ensure_schema(self) -> None:
        """Apply the ingest-queue schema on the producer's already-open pool.

        Lets the API lifespan provision the schema without a second open/close
        cycle (it already opened the connector to build this producer) — the
        ``worker`` command shares the same single-open pattern.
        """
        await _apply_ingest_queue_schema_open(self._app)

    async def job_counts(self) -> dict[str, int]:
        """Ingest job counts by status (for the vector-sync status surface)."""
        return await get_ingest_job_counts(self._app)

    async def job_counts_by_queue(self) -> dict[str, dict[str, int]]:
        """Per-tier-queue ingest job counts by status (Deck #323)."""
        return await get_ingest_job_counts_by_queue(self._app)

    def clone(self) -> ProcrastinateTaskProducer:
        return self

    async def __aenter__(self) -> ProcrastinateTaskProducer:
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        return None

    # The bare suppression marker silences S7503 (async method without await):
    # ``async def`` is required by the TaskProducer protocol; per-handle close is
    # a no-op (the pool is owned by the lifespan, drained once on shutdown).
    async def aclose(self) -> None:  # NOSONAR
        return None

    async def drain(self) -> None:
        """Close the shared connector pool (lifespan shutdown only)."""
        await self._app.close_async()
