"""FC-3k: /api/admin — destructive admin actions.

Action surfaces:
  POST   /api/admin/artists/<slug>/cascade-delete  (Tier C)
  POST   /api/admin/images/bulk-delete             (Tier C)
  DELETE /api/admin/tags/<int:tag_id>              (Tier B)
  POST   /api/admin/tags/<int:dest_id>/merge       (Tier B)
  POST   /api/admin/tags/prune-unused              (Tier A)
  POST   /api/admin/posts/prune-bare               (Tier A)
  POST   /api/admin/posts/refetch-external         (Tier A)
  GET    /api/admin/tags/<int:tag_id>/usage-count  (helper)

Tier-C ops take a dry_run body flag (returns projection inline,
no dispatch) and a confirm body field (server-recomputed token).
Long-running ops dispatch a maintenance-queue Celery task; the UI
tails FC-3i's /api/system/activity/runs to surface progress.
"""
from __future__ import annotations

import hashlib

from quart import Blueprint, jsonify, request
from sqlalchemy import select, text

from ..extensions import get_session
from ..models import Artist, Post
from ..services.cleanup_service import project_artist_cascade, project_bulk_image_delete
from ._responses import error_response as _bad

admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")


def _bulk_image_confirm_token(image_ids: list[int]) -> str:
    """Stable 8-hex token derived from the sorted id list. Mutates
    when the selection changes; stays the same across modal opens of
    the same selection so the operator can paste without confusion."""
    canon = ",".join(str(i) for i in sorted(image_ids))
    digest = hashlib.sha256(canon.encode("utf-8")).hexdigest()
    return digest[:8]


async def _run_dry_run_op(service_fn, **service_kwargs):
    """Shared body for the Tier-A dry-run/apply endpoints: read the `dry_run`
    flag, run the cleanup_service predicate under `run_sync`, and return its
    result dict. The SAME `service_fn` drives both preview and apply (the flag
    just toggles), so a handler physically can't let its preview diverge from
    its delete (rule 93). Default False preserves the existing contract — the UI
    always passes `dry_run` explicitly (true to preview, false to apply). Extra
    service kwargs (e.g. `source_id`) pass straight through."""
    body = await request.get_json(silent=True) or {}
    dry_run = bool(body.get("dry_run", False))
    async with get_session() as session:
        result = await session.run_sync(
            lambda sync_sess: service_fn(sync_sess, dry_run=dry_run, **service_kwargs)
        )
    return jsonify(result)


def _queued(async_result):
    """Standard 202 for an operator-triggered maintenance task: hand the UI the
    Celery task id so it can tail /maintenance/task-result (or the activity
    dashboard) for the summary. (trigger_vacuum stays bespoke — the UI doesn't
    poll it, so it returns no task id.)"""
    return jsonify({"task_id": async_result.id, "status": "queued"}), 202


@admin_bp.route("/artists/<slug>/cascade-delete", methods=["POST"])
async def artist_cascade_delete(slug: str):
    body = await request.get_json(silent=True) or {}
    dry_run = bool(body.get("dry_run", False))
    supplied_confirm = body.get("confirm", "")

    async with get_session() as session:
        artist = (await session.execute(
            select(Artist).where(Artist.slug == slug)
        )).scalar_one_or_none()
        if artist is None:
            return _bad("not_found", status=404)
        artist_id = artist.id

        projected = await session.run_sync(
            lambda sync_sess: project_artist_cascade(sync_sess, slug=slug)
        )

    if dry_run:
        return jsonify(projected)

    expected = f"delete-artist-{artist_id}"
    if supplied_confirm != expected:
        return _bad(
            "confirm_mismatch",
            detail=f"confirm must equal {expected!r}",
            expected=expected,
        )

    from ..tasks.admin import delete_artist_cascade_task
    async_result = delete_artist_cascade_task.delay(artist_id=artist_id)
    return jsonify({"task_id": async_result.id}), 202


@admin_bp.route("/images/bulk-delete", methods=["POST"])
async def images_bulk_delete():
    body = await request.get_json(silent=True) or {}
    image_ids = body.get("image_ids")
    if not isinstance(image_ids, list) or not image_ids:
        return _bad("invalid_image_ids", detail="image_ids must be non-empty list of int")
    try:
        image_ids = [int(i) for i in image_ids]
    except (TypeError, ValueError):
        return _bad("invalid_image_ids", detail="image_ids must contain only ints")

    dry_run = bool(body.get("dry_run", False))
    supplied_confirm = body.get("confirm", "")

    async with get_session() as session:
        projected = await session.run_sync(
            lambda sync_sess: project_bulk_image_delete(
                sync_sess, image_ids=image_ids,
            )
        )

    sha8 = _bulk_image_confirm_token(image_ids)
    expected = f"delete-images-{sha8}"

    if dry_run:
        # Hand the canonical Tier-C confirm token back with the
        # projection so the frontend doesn't have to recompute SHA-256
        # client-side via crypto.subtle (Secure-Context-gated,
        # undefined on plain-HTTP origins per the homelab posture).
        # Operator-flagged 2026-05-27.
        projected["confirm_token"] = expected
        return jsonify(projected)


    if supplied_confirm != expected:
        return _bad(
            "confirm_mismatch",
            detail=f"confirm must equal {expected!r}",
            expected=expected,
        )

    from ..tasks.admin import bulk_delete_images_task
    async_result = bulk_delete_images_task.delay(image_ids=image_ids)
    return jsonify({"task_id": async_result.id}), 202


@admin_bp.route("/tags/<int:tag_id>", methods=["DELETE"])
async def tag_delete(tag_id: int):
    """Tier-B sync delete. UI yes/no modal is the only confirmation."""
    from ..services.cleanup_service import delete_tag

    async with get_session() as session:
        try:
            result = await session.run_sync(
                lambda sync_sess: delete_tag(sync_sess, tag_id=tag_id)
            )
        except LookupError:
            return _bad("not_found", status=404)
        except ValueError as exc:
            # System tags (#128) — the training-hygiene machinery keys on
            # these rows.
            return _bad("system_tag", detail=str(exc))
    return jsonify(result)


@admin_bp.route("/tags/<int:dest_id>/merge", methods=["POST"])
async def tag_merge(dest_id: int):
    """Wraps TagService.merge. Source repoints to dest, dest survives,
    source row deleted, protective alias auto-created if source was
    ML-applied or allowlisted."""
    from ..services.tag_service import TagMergeConflict, TagService, TagValidationError

    body = await request.get_json(silent=True) or {}
    source_id = body.get("source_id")
    if not isinstance(source_id, int) or source_id == dest_id:
        return _bad("invalid_source_id", detail="source_id must be int and differ from dest")

    # dry_run: non-mutating preview (counts + sample) so the operator can
    # confirm the target before the irreversible merge (#8, rule 93 parity).
    if body.get("dry_run"):
        async with get_session() as session:
            try:
                p = await TagService(session).merge_preview(
                    source_id=source_id, target_id=dest_id,
                )
            except TagValidationError as exc:
                return _bad("tag_not_found", status=404, detail=str(exc))
        return jsonify({
            "preview": {
                "source_id": p.source_id, "source_name": p.source_name,
                "target_id": p.target_id, "target_name": p.target_name,
                "compatible": p.compatible,
                "images_moving": p.images_moving,
                "images_already_on_target": p.images_already_on_target,
                "source_total": p.source_total,
                "series_pages": p.series_pages,
                "will_alias": p.will_alias,
                "sample_thumbnails": p.sample_thumbnails,
            },
        })

    async with get_session() as session:
        try:
            result = await TagService(session).merge(
                source_id=source_id, target_id=dest_id,
            )
        except TagMergeConflict as exc:
            return _bad("merge_conflict", status=409, detail=str(exc))
        except TagValidationError as exc:
            return _bad("tag_kind_mismatch", detail=str(exc))
        except LookupError:
            return _bad("not_found", status=404)

    # MergeResult is a frozen dataclass — flatten to dict.
    return jsonify({
        "result": {
            "target_id": result.target_id,
            "target_name": result.target_name,
            "target_kind": result.target_kind,
            "merged_count": result.merged_count,
            "alias_created": result.alias_created,
            "source_deleted": result.source_deleted,
        },
    })


@admin_bp.route("/tags/<int:tag_id>/usage-count", methods=["GET"])
async def tag_usage_count(tag_id: int):
    """Helper for the Tier-B yes/no prompt; surfaces "N associations"
    in the dialog so the operator knows what they're nuking."""
    from ..services.cleanup_service import count_tag_associations

    async with get_session() as session:
        count = await session.run_sync(
            lambda sync_sess: count_tag_associations(
                sync_sess, tag_id=tag_id,
            )
        )
    return jsonify({"count": count})


@admin_bp.route("/tags/prune-unused", methods=["POST"])
async def tags_prune_unused():
    """Tier-A: dry-run preview list IS the prompt. UI calls with
    dry_run=true first, shows the list, operator clicks button to
    re-call with dry_run=false."""
    from ..services.cleanup_service import prune_unused_tags

    return await _run_dry_run_op(prune_unused_tags)


@admin_bp.route("/posts/prune-bare", methods=["POST"])
async def posts_prune_bare():
    """Tier-A: delete bare posts — Post rows with no linked images (primary OR
    provenance) and no attachments. Dry-run preview list IS the prompt: UI calls
    with dry_run=true first, shows the count + sample, operator confirms by
    re-calling with dry_run=false. Same preview/apply-parity predicate as the
    prune itself, so the preview can't diverge from the delete."""
    from ..services.cleanup_service import prune_bare_posts

    return await _run_dry_run_op(prune_bare_posts)


@admin_bp.route("/posts/reconcile-duplicates", methods=["POST"])
async def posts_reconcile_duplicates():
    """Tier-A: unify duplicate post rows for the same real post — the gallery-dl
    (attachment-id) + native (post-id) duplicates — onto ONE post-id-keyed keeper,
    moving image/provenance/attachment/link rows over. Images are untouched.
    dry_run=true returns {groups, posts_to_merge, sample}; dry_run=false applies
    and returns {groups, merged, sample}. Optional source_id scopes to one source.
    Same find_duplicate_post_groups predicate drives preview + apply (rule 93)."""
    from ..services.cleanup_service import reconcile_duplicate_posts

    body = await request.get_json(silent=True) or {}
    raw_source = body.get("source_id")
    try:
        source_id = int(raw_source) if raw_source is not None else None
    except (TypeError, ValueError):
        return _bad("invalid_source_id", detail="source_id must be an integer")

    return await _run_dry_run_op(reconcile_duplicate_posts, source_id=source_id)


@admin_bp.route("/posts/refetch-external", methods=["POST"])
async def posts_refetch_external():
    """Surgical re-fetch of a post's external file-host links (operator
    2026-07-03): the normal cadence never re-walks deep back-catalogue posts,
    so a deleted external file only comes back by resetting its ExternalLink
    row(s) — this endpoint does that per post and dispatches the fetches.
    Sha-dedupe discards payload files that still exist, so only what's
    missing lands. Body: {external_post_id: str, source_id?: int (to
    disambiguate the same external id across sources)}."""
    from ..services.external_links import refetch_links_for_post

    body = await request.get_json(silent=True) or {}
    ext_id = str(body.get("external_post_id") or "").strip()
    if not ext_id:
        return _bad("missing_external_post_id",
                    detail="external_post_id is required")
    raw_source = body.get("source_id")
    try:
        source_id = int(raw_source) if raw_source is not None else None
    except (TypeError, ValueError):
        return _bad("invalid_source_id", detail="source_id must be an integer")

    async with get_session() as session:
        stmt = select(Post.id).where(Post.external_post_id == ext_id)
        if source_id is not None:
            stmt = stmt.where(Post.source_id == source_id)
        post_ids = (await session.execute(stmt)).scalars().all()
        if not post_ids:
            return _bad("post_not_found", status=404,
                        detail=f"no post with external_post_id {ext_id!r}")
        results = {}
        for pid in post_ids:
            results[str(pid)] = await session.run_sync(
                lambda s, p=pid: refetch_links_for_post(s, p)
            )
    return jsonify({"posts": results})


def _reset_content_confirm_token(projection: dict) -> str:
    """Stable 8-hex token derived from the live counts (mirrors the Tier-C
    bulk-delete token): it changes whenever the data changes, so the apply can
    only ever run against numbers the operator just previewed."""
    canon = f"reset-content:{projection.get('count')}:{projection.get('applications')}"
    return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:8]


@admin_bp.route("/tags/reset-content", methods=["POST"])
async def tags_reset_content():
    """Full-instance reset of the CONTENT vocabulary: deletes ALL general +
    character tags and their image applications — INCLUDING the examples the
    tagging heads learned from. Suggestions do NOT repopulate on their own
    (the Camie predictions that once did are long retired): the operator
    re-tags from scratch and the heads retrain from the new signal. fandom +
    series tags + series_page ordering are preserved.

    Deliberately Tier-C-gated despite the Tier-A shape (operator 2026-07-02:
    the full reset stays, but behind extra steps): dry_run returns the
    projection + a `confirm` token derived from the live counts; the apply
    must echo that token back or it is rejected."""
    from ..services.cleanup_service import reset_content_tagging

    body = await request.get_json(silent=True) or {}
    dry_run = bool(body.get("dry_run", False))
    async with get_session() as session:
        projection = await session.run_sync(
            lambda s: reset_content_tagging(s, dry_run=True)
        )
        token = _reset_content_confirm_token(projection)
        if dry_run:
            projection["confirm"] = token
            return jsonify(projection)
        if str(body.get("confirm", "")) != token:
            return _bad(
                "confirm_mismatch",
                detail="run a fresh preview and echo its confirm token",
            )
        result = await session.run_sync(
            lambda s: reset_content_tagging(s, dry_run=False)
        )
    return jsonify(result)


@admin_bp.route("/tags/normalize", methods=["POST"])
async def tags_normalize():
    """#714: retro-normalize existing tags to the #701 canonical form (Title
    Case + collapsed whitespace) and merge case/whitespace-variant duplicates.

    dry_run=true (default) returns a projection inline — group/collision/rename
    counts + a sample of the changes — so the UI shows exactly what'll happen.
    dry_run=false dispatches the long-running maintenance task (the merge FK
    repoints can touch many tags); the UI tails the activity dashboard for the
    summary. Idempotent; back up first (the merges are irreversible)."""
    from ..services.tag_service import normalize_existing_tags

    body = await request.get_json(silent=True) or {}
    dry_run = bool(body.get("dry_run", True))

    if dry_run:
        async with get_session() as session:
            result = await normalize_existing_tags(session, dry_run=True)
        return jsonify(result)

    from ..tasks.admin import normalize_tags_task

    async_result = normalize_tags_task.delay()
    return _queued(async_result)


@admin_bp.route("/maintenance/db-stats", methods=["GET"])
async def db_stats():
    """Per-table bloat readout (pg_stat_user_tables) for the high-churn tables
    so the operator can see when a VACUUM is worth running."""
    from ..tasks.maintenance import VACUUM_TABLES

    wanted = set(VACUUM_TABLES)
    async with get_session() as session:
        rows = (await session.execute(text(
            "SELECT relname, n_live_tup, n_dead_tup, last_vacuum, "
            "last_autovacuum, last_analyze FROM pg_stat_user_tables"
        ))).all()

    def _iso(v):
        return v.isoformat() if v is not None else None

    out = []
    for r in rows:
        if r.relname not in wanted:
            continue
        live = r.n_live_tup or 0
        dead = r.n_dead_tup or 0
        total = live + dead
        out.append({
            "table": r.relname,
            "live": live,
            "dead": dead,
            "dead_pct": round(100 * dead / total, 1) if total else 0.0,
            "last_vacuum": _iso(r.last_vacuum),
            "last_autovacuum": _iso(r.last_autovacuum),
            "last_analyze": _iso(r.last_analyze),
        })
    out.sort(key=lambda t: t["dead"], reverse=True)
    return jsonify({"tables": out})


@admin_bp.route("/maintenance/vacuum", methods=["POST"])
async def trigger_vacuum():
    """Operator-triggered VACUUM (ANALYZE) over the high-churn tables — the
    same maintenance-queue task the weekly Beat schedule runs."""
    from ..tasks.maintenance import vacuum_analyze

    vacuum_analyze.delay()
    return jsonify({"status": "queued"}), 202


@admin_bp.route("/maintenance/reextract-archives", methods=["POST"])
async def trigger_reextract_archives():
    """Operator-triggered re-extract (#713): PostAttachments that are actually
    archives but were filed opaquely (pre magic-byte gate) get extracted and
    their members linked to the post. Idempotent; runs on the maintenance queue."""
    from ..tasks.admin import reextract_archive_attachments_task

    async_result = reextract_archive_attachments_task.delay()
    return _queued(async_result)


@admin_bp.route("/maintenance/prune-missing-files", methods=["POST"])
async def trigger_prune_missing_files():
    """Operator-triggered orphan repair (#859): delete ImageRecords whose backing
    file is gone from disk (e.g. left by the external-attach unlink bug), so they
    stop 404-ing on playback. The task aborts WITHOUT deleting if a large fraction
    of files look missing (a filesystem/NFS stall). Maintenance queue;
    operator-triggered only — never an unattended sweep."""
    from ..tasks.admin import prune_missing_file_records_task

    async_result = prune_missing_file_records_task.delay()
    return _queued(async_result)


@admin_bp.route("/maintenance/reclaim-attachments", methods=["POST"])
async def trigger_reclaim_attachments():
    """Reclaim orphaned attachments (#3068). Body {"dry_run": bool}: dry_run
    (the DEFAULT here) projects the orphan rows and unreferenced store blobs
    without touching either; dry_run=false deletes the rows then unlinks every
    blob no surviving row references. Maintenance queue; operator-triggered
    only — never an unattended sweep, since the apply unlinks files. Returns the
    Celery task id — poll /maintenance/task-result/<id> for the summary."""
    from ..tasks.admin import reclaim_orphaned_attachments_task

    body = await request.get_json(silent=True) or {}
    dry_run = bool(body.get("dry_run", True))  # default to the SAFE preview
    async_result = reclaim_orphaned_attachments_task.delay(dry_run=dry_run)
    return _queued(async_result)


@admin_bp.route("/maintenance/dedup-videos", methods=["POST"])
async def trigger_dedup_videos():
    """Tier-1 video dedup (#871). Body {"dry_run": bool}: dry_run=true previews
    what would be removed (groups / redundant count / reclaimable bytes) WITHOUT
    deleting; dry_run=false applies it (re-link posts to the keeper, then delete
    the redundant copies). Either way it first re-probes NULL-duration videos so
    the existing library participates. Returns the Celery task id — poll
    /maintenance/task-result/<id> for the summary."""
    from ..tasks.admin import dedup_videos_task

    body = await request.get_json(silent=True) or {}
    dry_run = bool(body.get("dry_run", True))  # default to the SAFE preview
    async_result = dedup_videos_task.delay(dry_run=dry_run)
    return _queued(async_result)


@admin_bp.route("/maintenance/purge-gated-previews", methods=["POST"])
async def trigger_purge_gated_previews():
    """Cleanup (#874 follow-up). Body {"dry_run": bool}: dry_run=true previews how
    many blurred locked-preview images (grabbed from tier-gated Patreon posts
    before the fix) would be removed WITHOUT deleting; dry_run=false applies it.
    Re-walks every enabled Patreon source read-only and matches by content hash, so
    real content downloaded when access existed is provably spared. Returns the
    Celery task id — poll /maintenance/task-result/<id> for the summary."""
    from ..tasks.admin import purge_gated_previews_task

    body = await request.get_json(silent=True) or {}
    dry_run = bool(body.get("dry_run", True))  # default to the SAFE preview
    async_result = purge_gated_previews_task.delay(dry_run=dry_run)
    return _queued(async_result)


@admin_bp.route("/maintenance/task-result/<task_id>", methods=["GET"])
async def maintenance_task_result(task_id: str):
    """Poll a maintenance Celery task's result (the summary dict it returns).
    Used by the video-dedup card to show the dry-run projection before apply."""
    from ..celery_app import celery

    res = celery.AsyncResult(task_id)
    ready = res.ready()
    return jsonify({
        "ready": ready,
        "successful": res.successful() if ready else None,
        "result": res.result if (ready and res.successful()) else None,
    })
