"""FC-3k: admin destructive Celery tasks.

Two long-running ops on the maintenance queue. task_run lifecycle is
captured automatically by FC-3i signals — these tasks just return
their summary dict so it lands in task_run.metadata (via Celery's
result backend) for the dashboard to surface.

Soft/hard time limits inherit the FC-3i recovery sweep: a runaway
task gets killed and flipped to status='timeout' by
recover_stalled_task_runs.
"""
from __future__ import annotations

import logging
from pathlib import Path

from sqlalchemy import delete, select
from sqlalchemy.exc import DBAPIError, OperationalError

from ..celery_app import celery
from ..models import ImageRecord
from ..services import cleanup_service
from ._sync_engine import sync_session_factory as _sync_session_factory

log = logging.getLogger(__name__)
IMAGES_ROOT = Path("/images")


@celery.task(
    name="backend.app.tasks.admin.delete_artist_cascade_task",
    bind=True,
    autoretry_for=(OperationalError, DBAPIError),
    retry_backoff=15, retry_backoff_max=180, max_retries=1,
    soft_time_limit=1800, time_limit=2400,  # 30 min / 40 min
)
def delete_artist_cascade_task(self, *, artist_id: int) -> dict:
    """Wraps cleanup_service.delete_artist_cascade. Returns the
    service's summary dict for FC-3i task_run.metadata capture."""
    SessionLocal = _sync_session_factory()
    with SessionLocal() as session:
        return cleanup_service.delete_artist_cascade(
            session, artist_id=artist_id, images_root=IMAGES_ROOT,
        )


# Orphan repair (#859). Safety guard: an NFS/filesystem stall makes EVERY file
# look missing — never delete records en masse on that basis. If a non-trivial
# sample comes back mostly missing, ABORT without deleting (assume the FS is
# unhealthy, not that the library evaporated). Operator-triggered ONLY — NOT a
# periodic sweep, precisely to avoid an unattended run firing during an NFS blip.
_ORPHAN_MIN_SAMPLE = 50
_ORPHAN_MAX_MISSING_FRAC = 0.10


@celery.task(
    name="backend.app.tasks.admin.prune_missing_file_records_task",
    bind=True,
    autoretry_for=(OperationalError, DBAPIError),
    retry_backoff=15, retry_backoff_max=180, max_retries=1,
    soft_time_limit=1800, time_limit=2400,  # 30 min / 40 min
)
def prune_missing_file_records_task(self) -> dict:
    """Delete ImageRecords whose backing file is gone from disk (orphans — e.g.
    the external-attach unlink bug #859). Every FK to image_record is CASCADE /
    SET NULL, so a Core DELETE cleans provenance, series pages, predictions and
    tag links; leftover thumbnails are unlinked best-effort. Returns a summary.

    Aborts WITHOUT deleting if a non-trivial sample is mostly missing (a
    filesystem/NFS stall, not real orphans) — see the guard constants above.
    """
    SessionLocal = _sync_session_factory()
    checked = 0
    missing_ids: list[int] = []
    thumbs: list[str] = []
    last_id = 0
    with SessionLocal() as session:
        while True:
            rows = session.execute(
                select(ImageRecord.id, ImageRecord.path, ImageRecord.thumbnail_path)
                .where(ImageRecord.id > last_id)
                .order_by(ImageRecord.id)
                .limit(1000)
            ).all()
            if not rows:
                break
            for rid, path, thumb in rows:
                last_id = rid
                checked += 1
                if not (IMAGES_ROOT / path).exists():
                    missing_ids.append(rid)
                    if thumb:
                        thumbs.append(thumb)

        if not missing_ids:
            return {"checked": checked, "missing": 0, "deleted": 0}

        frac = len(missing_ids) / checked if checked else 0.0
        if checked >= _ORPHAN_MIN_SAMPLE and frac > _ORPHAN_MAX_MISSING_FRAC:
            log.warning(
                "orphan-repair ABORTED: %d/%d (%.0f%%) records missing on disk — "
                "likely a filesystem/NFS problem, not real orphans. No deletions.",
                len(missing_ids), checked, frac * 100,
            )
            return {
                "checked": checked, "missing": len(missing_ids), "deleted": 0,
                "aborted": "too many missing — filesystem problem suspected",
            }

        deleted = 0
        for i in range(0, len(missing_ids), 500):  # keep well under psycopg's param ceiling
            chunk = missing_ids[i:i + 500]
            session.execute(delete(ImageRecord).where(ImageRecord.id.in_(chunk)))
            deleted += len(chunk)
        session.commit()

    for t in thumbs:  # cosmetic — outside the txn, never fail the repair on these
        try:
            (IMAGES_ROOT / t).unlink(missing_ok=True)
        except OSError:
            pass
    log.info("orphan-repair: checked=%d missing=%d deleted=%d", checked, len(missing_ids), deleted)
    return {"checked": checked, "missing": len(missing_ids), "deleted": deleted}


@celery.task(
    name="backend.app.tasks.admin.dedup_videos_task",
    bind=True,
    autoretry_for=(OperationalError, DBAPIError),
    retry_backoff=15, retry_backoff_max=180, max_retries=1,
    soft_time_limit=1800, time_limit=2400,  # 30 min / 40 min
)
def dedup_videos_task(self, dry_run: bool = False) -> dict:
    """Tier-1 video dedup (#871): re-probe NULL-duration videos, cluster by
    artist + duration + aspect, keep the highest-res copy per cluster. dry_run
    returns the projection (groups/redundant/reclaimable bytes) WITHOUT deleting;
    apply re-points each loser's post links to the keeper then deletes the
    redundant records + files. Operator-triggered; the summary lands in
    task_run.metadata (FC-3i) for the Maintenance card to surface."""
    SessionLocal = _sync_session_factory()
    with SessionLocal() as session:
        return cleanup_service.dedup_videos(
            session, images_root=IMAGES_ROOT, dry_run=dry_run,
        )


# Wall-clock budget for the gated-preview re-walk: stop walking new sources past
# this and report partial (operator re-runs to finish). Sits under the soft limit
# so the task returns its summary cleanly instead of being SIGKILLed mid-walk.
_GATED_PURGE_BUDGET_SECONDS = 1500


@celery.task(
    name="backend.app.tasks.admin.purge_gated_previews_task",
    bind=True,
    autoretry_for=(OperationalError, DBAPIError),
    retry_backoff=15, retry_backoff_max=180, max_retries=1,
    soft_time_limit=1800, time_limit=2400,  # 30 min / 40 min
)
def purge_gated_previews_task(self, dry_run: bool = True) -> dict:
    """Cleanup (#874 follow-up): purge blurred locked-preview images grabbed from
    tier-gated Patreon posts before the ingester fix. Re-walks every enabled
    Patreon source (read-only, no downloads) to re-derive which posts are gated NOW
    and the blurred filehashes Patreon serves for them, then matches by content
    hash and (unless dry_run) deletes only those exact files — real content
    downloaded when access existed has a different hash and is provably spared.

    dry_run=True returns the projection (gated posts / matched images / reclaimable
    bytes / unverifiable kept) WITHOUT deleting; dry_run=false applies it. The
    summary lands in task_run.metadata (FC-3i) for the Maintenance card. Time-boxed
    across sources: a `partial` result means re-run to finish (idempotent — a
    re-walk re-derives the same matches and already-deleted files stay deleted)."""
    import asyncio
    import time as _time

    from sqlalchemy import select as _select

    from ..models import Source
    from ..services.credential_crypto import CredentialCrypto
    from ..services.credential_service import CredentialService
    from ..services.patreon_client import PatreonClient
    from ..services.patreon_resolver import resolve_campaign_id_for_source
    from ._async_session import async_session_factory

    key_path = IMAGES_ROOT / "secrets" / "credential_key.b64"

    async def _run() -> dict:
        async_factory, async_engine = async_session_factory()
        SessionLocal = _sync_session_factory()
        try:
            async with async_factory() as async_session:
                cred = CredentialService(async_session, CredentialCrypto(key_path))
                cookies_obj = await cred.get_cookies_path("patreon")
            cookies_path = str(cookies_obj) if cookies_obj else None

            with SessionLocal() as session:
                sources = session.execute(
                    _select(Source.id, Source.url, Source.config_overrides)
                    .where(Source.platform == "patreon", Source.enabled.is_(True))
                ).all()

            gated_map: dict[int, dict] = {}
            scanned = 0
            partial = False
            loop = asyncio.get_running_loop()
            start = _time.monotonic()
            for sid, url, overrides in sources:
                if _time.monotonic() - start >= _GATED_PURGE_BUDGET_SECONDS:
                    partial = True
                    break
                campaign_id, _resolved = await resolve_campaign_id_for_source(
                    url, cookies_path, overrides or {}
                )
                if not campaign_id:
                    log.warning(
                        "gated-purge: couldn't resolve campaign for source %s (%s) "
                        "— skipping", sid, url,
                    )
                    continue
                client = PatreonClient(cookies_path)
                try:
                    gated = await loop.run_in_executor(
                        None, cleanup_service.collect_gated_previews, client, campaign_id
                    )
                except Exception as exc:  # one bad feed must not strand the sweep
                    log.warning("gated-purge walk failed for source %s: %s", sid, exc)
                    continue
                scanned += 1
                if gated:
                    gated_map[int(sid)] = gated

            with SessionLocal() as session:
                summary = cleanup_service.purge_gated_previews(
                    session, gated_map=gated_map, images_root=IMAGES_ROOT,
                    dry_run=dry_run,
                )
            summary["sources_scanned"] = scanned
            summary["sources_total"] = len(sources)
            summary["partial"] = partial
            return summary
        finally:
            await async_engine.dispose()

    return asyncio.run(_run())


@celery.task(
    name="backend.app.tasks.admin.bulk_delete_images_task",
    bind=True,
    autoretry_for=(OperationalError, DBAPIError),
    retry_backoff=15, retry_backoff_max=180, max_retries=1,
    soft_time_limit=900, time_limit=1200,  # 15 min / 20 min
)
def bulk_delete_images_task(self, *, image_ids: list[int]) -> dict:
    """Wraps cleanup_service.delete_images."""
    SessionLocal = _sync_session_factory()
    with SessionLocal() as session:
        return cleanup_service.delete_images(
            session, image_ids=image_ids, images_root=IMAGES_ROOT,
        )


# Time-box one chunk well under the soft limit so a large archive back-catalog
# can't run the task into the Celery time limit (or hog the maintenance_long
# lane). The task re-enqueues itself with the resume cursor until the scan is
# exhausted — mirrors normalize_tags_task (operator-asked 2026-06-07: reasonable
# timeout, then re-queue so other work keeps flowing).
_REEXTRACT_CHUNK_SECONDS = 600


@celery.task(
    name="backend.app.tasks.admin.reextract_archive_attachments_task",
    bind=True,
    autoretry_for=(OperationalError, DBAPIError),
    retry_backoff=15, retry_backoff_max=180, max_retries=1,
    soft_time_limit=1800, time_limit=2400,  # 30 min / 40 min
)
def reextract_archive_attachments_task(self, after_id: int = 0) -> dict:
    """Wraps cleanup_service.reextract_archive_attachments (#713 part 2):
    re-extract PostAttachments that are actually archives but were filed
    opaquely before the magic-byte gate, and link their members to the post.

    Time-boxed + self-resuming: scans attachments after ``after_id`` and, on a
    chunk cut, re-enqueues from where it stopped so a big backlog finishes across
    chunks instead of dying at the soft limit."""
    SessionLocal = _sync_session_factory()
    with SessionLocal() as session:
        summary = cleanup_service.reextract_archive_attachments(
            session, images_root=IMAGES_ROOT,
            time_budget_seconds=_REEXTRACT_CHUNK_SECONDS, after_id=after_id,
        )
    # More attachments past this chunk's cursor — continue in the next.
    if summary.get("partial") and summary.get("resume_after_id", 0) > after_id:
        log.info(
            "reextract chunk done (%d scanned, %d archives, resume after id %s) "
            "— re-enqueuing to continue",
            summary.get("scanned", 0), summary.get("archives", 0),
            summary["resume_after_id"],
        )
        reextract_archive_attachments_task.delay(summary["resume_after_id"])
    return summary


# Time-box one chunk well under the soft limit so a large back-catalog (the
# first run recases the whole booru vocabulary) can't run the task into the
# Celery time limit — it timed out at 40 min, operator-flagged 2026-06-07. The
# task re-enqueues itself until nothing remains (idempotent — already-canonical
# groups are skipped). 600s keeps each chunk short enough that the recovery
# sweep and other maintenance tasks interleave on the concurrency-1 queue.
_NORMALIZE_CHUNK_SECONDS = 600


@celery.task(
    name="backend.app.tasks.admin.normalize_tags_task",
    bind=True,
    autoretry_for=(OperationalError, DBAPIError),
    retry_backoff=15, retry_backoff_max=180, max_retries=1,
    soft_time_limit=1800, time_limit=2400,  # 30 min / 40 min
)
def normalize_tags_task(self) -> dict:
    """Wraps tag_service.normalize_existing_tags (#714): Title-Case the
    back-catalog and merge case/whitespace-variant duplicate tags via the
    tested async merge path. Time-boxed + self-resuming so a huge first run
    finishes across chunks instead of timing out. Runs under its own asyncio
    loop + per-task async engine (NullPool), mirroring download_source."""
    import asyncio

    from ..services.tag_service import normalize_existing_tags
    from ._async_session import async_session_factory

    async def _run() -> dict:
        # lock_timeout=30s: a per-group merge repoints FKs across image_tag and
        # series_page; if a statement blocks on a lock (e.g. behind a schema
        # migration holding ACCESS EXCLUSIVE on series_page — the exact wedge that
        # made this task run to the 40-min hard limit with no progress,
        # operator-flagged 2026-06-07), it now fails fast. The per-group handler
        # catches it (rollback + error++) and the loop continues, so one blocked
        # group can't strand the whole chunk.
        async_factory, async_engine = async_session_factory(
            server_settings={"lock_timeout": "30s"}
        )
        try:
            async with async_factory() as session:
                # normalize_existing_tags commits per group internally.
                return await normalize_existing_tags(
                    session, dry_run=False,
                    time_budget_seconds=_NORMALIZE_CHUNK_SECONDS,
                )
        finally:
            await async_engine.dispose()

    summary = asyncio.run(_run())
    # More groups to canonicalize than fit this chunk — continue in the next.
    if summary.get("partial") and summary.get("remaining", 0) > 0:
        log.info(
            "normalize_tags_task chunk done (%d processed, %d remaining) — "
            "re-enqueuing to continue",
            summary.get("groups_processed", 0), summary["remaining"],
        )
        normalize_tags_task.delay()
    return summary


# Time-box one rescan chunk well under the soft limit and re-enqueue from the
# cursor — scoring every post against its artist's series is O(posts) and grows
# with the library (FC-6.3). Mirrors normalize_tags_task.
_SERIES_RESCAN_CHUNK_SECONDS = 600


@celery.task(
    name="backend.app.tasks.admin.rescan_series_suggestions_task",
    bind=True,
    autoretry_for=(OperationalError, DBAPIError),
    retry_backoff=15, retry_backoff_max=180, max_retries=1,
    soft_time_limit=1800, time_limit=2400,  # 30 min / 40 min
)
def rescan_series_suggestions_task(self, after_post_id: int = 0) -> dict:
    """Score posts against their artist's series and write pending suggestions
    (FC-6.3). Settings-gated; time-boxed + self-resuming from a post-id cursor.
    Per-task async engine (NullPool) under its own asyncio loop, like normalize."""
    import asyncio

    from ..models import ImportSettings
    from ..services.series_match_service import SeriesMatchService
    from ._async_session import async_session_factory

    async def _run() -> dict:
        async_factory, async_engine = async_session_factory()
        try:
            async with async_factory() as session:
                settings = await ImportSettings.load(session)
                if not settings.series_suggest_enabled:
                    return {"skipped": "series suggestions disabled"}
                threshold = settings.series_suggest_threshold
                return await SeriesMatchService(session).rescan(
                    threshold=threshold,
                    time_budget_seconds=_SERIES_RESCAN_CHUNK_SECONDS,
                    after_post_id=after_post_id,
                )
        finally:
            await async_engine.dispose()

    summary = asyncio.run(_run())
    if summary.get("partial") and summary.get("resume_after_id", 0) > after_post_id:
        log.info(
            "rescan_series_suggestions chunk done (%d scanned, %d suggested, "
            "resume after %s) — re-enqueuing",
            summary.get("scanned", 0), summary.get("suggested", 0),
            summary["resume_after_id"],
        )
        rescan_series_suggestions_task.delay(summary["resume_after_id"])
    return summary


@celery.task(
    name="backend.app.tasks.admin.reclaim_orphaned_attachments_task",
    bind=True,
    autoretry_for=(OperationalError, DBAPIError),
    retry_backoff=15, retry_backoff_max=180, max_retries=1,
    # The service stops walking at its own 900s budget and reports partial, so
    # these limits are the backstop for a wedged filesystem (NFS stall), not the
    # expected exit. Comfortably above the budget so a normal run always returns
    # its summary rather than being killed mid-walk.
    soft_time_limit=1200, time_limit=1500,  # 20 min / 25 min
)
def reclaim_orphaned_attachments_task(self, dry_run: bool = True) -> dict:
    """Reclaim unattributed PostAttachment rows and the store blobs nothing
    references any more (#3068). dry_run (the default) returns the projection
    without touching rows or files; apply deletes the orphan rows, then unlinks
    every blob no surviving row references.

    Defaults to the SAFE preview — unlike the other tasks here, whose apply is
    reversible-ish or scoped; this one deletes files. Operator-triggered only,
    never on a beat: an unattended sweep that unlinks blobs is not something to
    run without someone reading the projection first."""
    SessionLocal = _sync_session_factory()
    with SessionLocal() as session:
        return cleanup_service.reclaim_orphaned_attachments(
            session, images_root=IMAGES_ROOT, dry_run=dry_run,
        )
