"""FC-Cleanup: /api/cleanup/* — retroactive enforcement of import filters.

Endpoints:
  POST /min-dimension/preview     synchronous SQL audit
  POST /min-dimension/delete      synchronous SQL delete (Tier-C token)
  POST /audit                      async transparency / single_color start
  GET  /audit                      list recent audit_run rows
  GET  /audit/<id>                 single audit_run row
  POST /audit/<id>/apply           apply matched_ids deletes (Tier-C token)
  POST /audit/<id>/cancel          flip running audit to cancelled

Unused-tags retroactive prune intentionally NOT in this namespace —
TagMaintenanceCard (Maintenance tab → moved to Cleanup tab in v26.05.25.7)
uses the existing /api/admin/tags/prune-unused endpoint via the admin
store. No duplicate route here.

Confirm-token format matches modal/DestructiveConfirmModal.vue convention:
  `delete-min-dim-<sha8(w,h)>` for min-dim delete
  `delete-audit-<id>` for audit apply
(Modal hardcodes action ∈ {'restore', 'delete'}; "apply audit" is semantically a delete of the matched images, so we use `delete-audit-<id>`.)
"""

from __future__ import annotations

import hashlib
from pathlib import Path

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

from ..extensions import get_session
from ..models import LibraryAuditRun
from ..services import cleanup_service
from ._responses import error_response as _bad

cleanup_bp = Blueprint("cleanup", __name__, url_prefix="/api/cleanup")

IMAGES_ROOT = Path("/images")


def _min_dim_token(min_w: int, min_h: int) -> str:
    # SHA-256 (not MD5) — Web Crypto's subtle.digest rejects MD5; both
    # sides use SHA-256 truncated to 8 hex chars.
    canon = f"{min_w}x{min_h}"
    return f"delete-min-dim-{hashlib.sha256(canon.encode()).hexdigest()[:8]}"


def _serialize_audit_run(audit: LibraryAuditRun) -> dict:
    return {
        "id": audit.id,
        "rule": audit.rule,
        "params": audit.params,
        "status": audit.status,
        "started_at": audit.started_at.isoformat() if audit.started_at else None,
        "finished_at": audit.finished_at.isoformat() if audit.finished_at else None,
        "scanned_count": audit.scanned_count,
        "matched_count": audit.matched_count,
        "matched_ids": audit.matched_ids,
        "error": audit.error,
    }


@cleanup_bp.route("/min-dimension/preview", methods=["POST"])
async def min_dim_preview():
    body = await request.get_json(silent=True) or {}
    try:
        min_w = int(body.get("min_width", 0))
        min_h = int(body.get("min_height", 0))
    except (TypeError, ValueError):
        return _bad("invalid_dimensions")
    if min_w < 0 or min_h < 0:
        return _bad("invalid_dimensions")
    async with get_session() as session:
        projection = await session.run_sync(
            lambda s: cleanup_service.project_min_dimension_violations(
                s, min_width=min_w, min_height=min_h,
            )
        )
    # Hand the canonical Tier-C delete token back with the preview so
    # the frontend doesn't have to recompute SHA-256 client-side.
    # window.crypto.subtle is Secure-Context-gated and undefined on
    # plain-HTTP origins (homelab posture); without this the Delete
    # button silently swallowed the TypeError and never opened the
    # confirm modal. Operator-flagged 2026-05-27.
    projection["confirm_token"] = _min_dim_token(min_w, min_h)
    return jsonify(projection)


@cleanup_bp.route("/min-dimension/delete", methods=["POST"])
async def min_dim_delete():
    body = await request.get_json(silent=True) or {}
    try:
        min_w = int(body.get("min_width", 0))
        min_h = int(body.get("min_height", 0))
    except (TypeError, ValueError):
        return _bad("invalid_dimensions")
    if min_w < 0 or min_h < 0:
        return _bad("invalid_dimensions")
    supplied = body.get("confirm", "")
    expected = _min_dim_token(min_w, min_h)
    if supplied != expected:
        return _bad("confirm_mismatch", expected=expected)
    async with get_session() as session:
        deleted = await session.run_sync(
            lambda s: cleanup_service.delete_min_dimension_violations(
                s, min_width=min_w, min_height=min_h, images_root=IMAGES_ROOT,
            )
        )
        await session.commit()
    return jsonify({"deleted": deleted})


@cleanup_bp.route("/audit", methods=["POST"])
async def audit_create():
    body = await request.get_json(silent=True) or {}
    rule = body.get("rule")
    params = body.get("params") or {}
    if rule not in ("transparency", "single_color"):
        return _bad("invalid_rule")
    if not isinstance(params, dict):
        return _bad("invalid_params")
    async with get_session() as session:
        try:
            audit_id = await session.run_sync(
                lambda s: cleanup_service.start_audit_run(
                    s, rule=rule, params=params,
                )
            )
        except cleanup_service.AuditAlreadyRunning as running_id:
            return _bad(
                "audit_already_running", status=409,
                running_id=int(str(running_id)),
            )
        except ValueError as exc:
            return _bad(str(exc))
        await session.commit()
    return jsonify({"audit_id": audit_id, "status": "running"}), 202


@cleanup_bp.route("/audit/<int:audit_id>", methods=["GET"])
async def audit_get(audit_id: int):
    async with get_session() as session:
        audit = (await session.execute(
            select(LibraryAuditRun).where(LibraryAuditRun.id == audit_id)
        )).scalar_one_or_none()
        if audit is None:
            return _bad("not_found", status=404)
        return jsonify(_serialize_audit_run(audit))


@cleanup_bp.route("/audit", methods=["GET"])
async def audit_history():
    try:
        limit = min(int(request.args.get("limit", "20")), 100)
    except ValueError:
        return _bad("invalid_limit")
    # Optional rule filter so a card can reconnect to ITS latest run on mount
    # (?rule=transparency&limit=1) — the audit survives navigation; the UI
    # rehydrates from this rather than losing the in-flight scan.
    rule = request.args.get("rule") or None
    async with get_session() as session:
        stmt = select(LibraryAuditRun).order_by(LibraryAuditRun.id.desc())
        if rule is not None:
            stmt = stmt.where(LibraryAuditRun.rule == rule)
        rows = (await session.execute(stmt.limit(limit))).scalars().all()
        return jsonify({"runs": [_serialize_audit_run(r) for r in rows]})


@cleanup_bp.route("/audit/<int:audit_id>/apply", methods=["POST"])
async def audit_apply(audit_id: int):
    body = await request.get_json(silent=True) or {}
    confirm = body.get("confirm", "")
    async with get_session() as session:
        try:
            deleted = await session.run_sync(
                lambda s: cleanup_service.apply_audit_run(
                    s, audit_id=audit_id, confirm_token=confirm,
                    images_root=IMAGES_ROOT,
                )
            )
        except cleanup_service.AuditNotReady as exc:
            return _bad("audit_not_ready", current_status=str(exc))
        except cleanup_service.ConfirmTokenMismatch as exc:
            return _bad("confirm_mismatch", expected=str(exc))
        except ValueError as exc:
            return _bad("not_found", status=404, detail=str(exc))
        await session.commit()
    return jsonify({"deleted": deleted})


@cleanup_bp.route("/audit/<int:audit_id>/cancel", methods=["POST"])
async def audit_cancel(audit_id: int):
    async with get_session() as session:
        await session.run_sync(
            lambda s: cleanup_service.cancel_audit_run(s, audit_id=audit_id)
        )
        await session.commit()
    return jsonify({"cancelled": True})
