"""Settings API: import filters, system stats."""

import asyncio
import secrets

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

from ..extensions import get_session
from ..models import (
    AppSetting,
    Artist,
    ImageRecord,
    ImportBatch,
    ImportSettings,
    ImportTask,
    Post,
    Tag,
    TaskRun,
)
from ..services import interpreter_client as ic

settings_bp = Blueprint("settings", __name__, url_prefix="/api")


_EDITABLE_FIELDS = (
    "min_width",
    "min_height",
    "skip_transparent",
    "transparency_threshold",
    "skip_single_color",
    "single_color_threshold",
    "single_color_tolerance",
    "phash_threshold",
    "download_rate_limit_seconds",
    "download_validate_files",
    "download_schedule_default_seconds",
    "download_event_retention_days",
    "download_failure_warning_threshold",
    "series_suggest_enabled",
    "series_suggest_threshold",
    "extdl_mega_enabled",
    "extdl_gdrive_enabled",
    "extdl_mediafire_enabled",
    "extdl_dropbox_enabled",
    "extdl_pixeldrain_enabled",
    "translation_enabled",
    "interpreter_base_url",
    "translation_target_lang",
    "translation_min_confidence",
    "wip_title_tagging_enabled",
    "wip_soft_title_tagging_enabled",
)

# Per-host external-download toggles — all plain booleans, validated uniformly.
_EXTDL_TOGGLE_FIELDS = (
    "extdl_mega_enabled",
    "extdl_gdrive_enabled",
    "extdl_mediafire_enabled",
    "extdl_dropbox_enabled",
    "extdl_pixeldrain_enabled",
)


@settings_bp.route("/settings/import", methods=["GET"])
async def get_import_settings():
    async with get_session() as session:
        row = await ImportSettings.load(session)
    # Table-driven off _EDITABLE_FIELDS (which PATCH also writes) so a new field
    # can't be silently absent from GET.
    return jsonify({f: getattr(row, f) for f in _EDITABLE_FIELDS})


@settings_bp.route("/settings/import", methods=["PATCH"])
async def update_import_settings():
    body = await request.get_json()
    if not isinstance(body, dict):
        return jsonify({"error": "body must be a JSON object"}), 400

    if "phash_threshold" in body and (
        not isinstance(body["phash_threshold"], int)
        or isinstance(body["phash_threshold"], bool)
        or body["phash_threshold"] < 0
    ):
        return jsonify(
            {"error": "phash_threshold must be a non-negative integer"}
        ), 400

    if "download_rate_limit_seconds" in body:
        val = body["download_rate_limit_seconds"]
        if not isinstance(val, (int, float)) or isinstance(val, bool) or val < 0:
            return jsonify(
                {"error": "download_rate_limit_seconds must be a non-negative number"}
            ), 400
    if "download_validate_files" in body and not isinstance(
        body["download_validate_files"], bool
    ):
        return jsonify(
            {"error": "download_validate_files must be a boolean"}
        ), 400

    # FC-3d scheduling knobs — bounds validation.
    def _bad_int(name: str, lo: int, hi: int):
        return jsonify(
            {"error": f"{name} must be an integer in [{lo}, {hi}]"}
        ), 400

    if "download_schedule_default_seconds" in body:
        v = body["download_schedule_default_seconds"]
        if not isinstance(v, int) or isinstance(v, bool) or v < 60 or v > 86400:
            return _bad_int("download_schedule_default_seconds", 60, 86400)
    if "download_event_retention_days" in body:
        v = body["download_event_retention_days"]
        if not isinstance(v, int) or isinstance(v, bool) or v < 1 or v > 3650:
            return _bad_int("download_event_retention_days", 1, 3650)
    if "download_failure_warning_threshold" in body:
        v = body["download_failure_warning_threshold"]
        if not isinstance(v, int) or isinstance(v, bool) or v < 1 or v > 100:
            return _bad_int("download_failure_warning_threshold", 1, 100)

    if "series_suggest_enabled" in body and not isinstance(
        body["series_suggest_enabled"], bool
    ):
        return jsonify(
            {"error": "series_suggest_enabled must be a boolean"}
        ), 400
    for tog in _EXTDL_TOGGLE_FIELDS:
        if tog in body and not isinstance(body[tog], bool):
            return jsonify({"error": f"{tog} must be a boolean"}), 400
    # Translation (#143): base URL may be empty (feature off until set — no
    # default host; the operator points it at their own Interpreter proxy).
    if "translation_enabled" in body and not isinstance(
        body["translation_enabled"], bool
    ):
        return jsonify({"error": "translation_enabled must be a boolean"}), 400
    for key in ("interpreter_base_url", "translation_target_lang"):
        if key in body and not isinstance(body[key], str):
            return jsonify({"error": f"{key} must be a string"}), 400
    # Acceptance floor (milestone 155): latin-script translations below this
    # Interpreter confidence are kept as the original.
    if "translation_min_confidence" in body:
        v = body["translation_min_confidence"]
        if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
            return jsonify(
                {"error": "translation_min_confidence must be a number in [0, 1]"}
            ), 400
    if "series_suggest_threshold" in body:
        v = body["series_suggest_threshold"]
        if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
            return jsonify(
                {"error": "series_suggest_threshold must be a number in [0, 1]"}
            ), 400
    if "wip_title_tagging_enabled" in body and not isinstance(
        body["wip_title_tagging_enabled"], bool
    ):
        return jsonify(
            {"error": "wip_title_tagging_enabled must be a boolean"}
        ), 400
    if "wip_soft_title_tagging_enabled" in body and not isinstance(
        body["wip_soft_title_tagging_enabled"], bool
    ):
        return jsonify(
            {"error": "wip_soft_title_tagging_enabled must be a boolean"}
        ), 400

    async with get_session() as session:
        row = await ImportSettings.load(session)
        for field in _EDITABLE_FIELDS:
            if field in body:
                setattr(row, field, body[field])
        await session.commit()

    return await get_import_settings()


@settings_bp.route("/settings/wip-title/scan", methods=["POST"])
async def wip_title_scan():
    """Enqueue the back-catalogue WIP-title scan (task #1458 Settings button):
    apply the `wip` system tag to EXISTING posts whose title declares
    work-in-progress. New imports are tagged live by the importer; this catches
    the existing library. Returns the Celery task id (202)."""
    from ..tasks.maintenance import backfill_wip_title_tags

    r = backfill_wip_title_tags.delay()
    return jsonify({"celery_task_id": r.id}), 202


@settings_bp.route("/system/stats", methods=["GET"])
async def system_stats():
    async with get_session() as session:
        total_images = (await session.execute(select(func.count(ImageRecord.id)))).scalar_one()
        total_tags = (await session.execute(select(func.count(Tag.id)))).scalar_one()
        storage_bytes = (
            (await session.execute(select(func.coalesce(func.sum(ImageRecord.size_bytes), 0)))).scalar_one()
        )
        subscription_count = (await session.execute(
            select(func.count(Artist.id)).where(Artist.is_subscription.is_(True))
        )).scalar_one()

        # Task counts grouped by status
        status_rows = (
            await session.execute(
                select(ImportTask.status, func.count(ImportTask.id)).group_by(ImportTask.status)
            )
        ).all()
        status_counts = {row[0]: row[1] for row in status_rows}

        # Integrity counts — FC-2e.
        integrity_rows = (
            await session.execute(
                select(
                    ImageRecord.integrity_status,
                    func.count(ImageRecord.id),
                ).group_by(ImageRecord.integrity_status)
            )
        ).all()
        integrity_counts = {row[0]: row[1] for row in integrity_rows}

        # Active batch = running batch that still has outstanding work.
        # Plain "most recent running" picks a freshly-created scan that
        # enqueued zero new files and hides the older batch that's
        # actually being processed; the EXISTS clause filters those
        # empty batches out.
        active_batch_row = (
            await session.execute(
                select(ImportBatch)
                .where(
                    ImportBatch.status == "running",
                    select(ImportTask.id)
                    .where(
                        ImportTask.batch_id == ImportBatch.id,
                        ImportTask.status.in_(["pending", "queued", "processing"]),
                    )
                    .exists(),
                )
                .order_by(ImportBatch.started_at.desc())
                .limit(1)
            )
        ).scalar_one_or_none()
        active_batch = None
        if active_batch_row:
            active_batch = {
                "id": active_batch_row.id,
                "source_path": active_batch_row.source_path,
                "started_at": active_batch_row.started_at.isoformat(),
                "total_files": active_batch_row.total_files,
                "imported": active_batch_row.imported,
                "skipped": active_batch_row.skipped,
                "failed": active_batch_row.failed,
            }

    return jsonify({
        "total_images": total_images,
        "total_tags": total_tags,
        "storage_bytes": storage_bytes,
        "subscription_count": int(subscription_count),
        "tasks": {
            "pending": status_counts.get("pending", 0),
            "queued": status_counts.get("queued", 0),
            "processing": status_counts.get("processing", 0),
            "complete": status_counts.get("complete", 0),
            "skipped": status_counts.get("skipped", 0),
            "failed": status_counts.get("failed", 0),
        },
        "integrity": {
            "unknown": int(integrity_counts.get("unknown", 0)),
            "ok": int(integrity_counts.get("ok", 0)),
            "corrupt": int(integrity_counts.get("corrupt", 0)),
            "failed_verification": int(
                integrity_counts.get("failed_verification", 0)
            ),
        },
        "active_batch": active_batch,
    })


# --- FC-3b: extension API key (lives in app_setting) -----------------------


async def _get_or_seed_extension_api_key(session) -> str:
    row = (await session.execute(
        select(AppSetting).where(AppSetting.key == "extension_api_key")
    )).scalar_one_or_none()
    if row is None:
        row = AppSetting(key="extension_api_key", value=secrets.token_urlsafe(32))
        session.add(row)
        await session.commit()
        await session.refresh(row)
    return row.value


@settings_bp.route("/settings/extension_api_key", methods=["GET"])
async def get_extension_api_key():
    async with get_session() as session:
        key = await _get_or_seed_extension_api_key(session)
    return jsonify({"key": key})


@settings_bp.route("/settings/extension_api_key/rotate", methods=["POST"])
async def rotate_extension_api_key():
    async with get_session() as session:
        row = (await session.execute(
            select(AppSetting).where(AppSetting.key == "extension_api_key")
        )).scalar_one_or_none()
        new_value = secrets.token_urlsafe(32)
        if row is None:
            row = AppSetting(key="extension_api_key", value=new_value)
            session.add(row)
        else:
            row.value = new_value
        await session.commit()
    return jsonify({"key": new_value})


# --- Translation (#143): live status + manual "Translate now" --------------


@settings_bp.route("/settings/translation/status", methods=["GET"])
async def translation_status():
    """For the Settings card: is it on, is a URL set, is the service reachable,
    and how many posts still await translation. Health runs the sync client in a
    thread so the event loop isn't blocked."""
    translation_tasks = (
        "backend.app.tasks.translation.translate_posts",
        "backend.app.tasks.translation.retranslate_posts",
    )
    async with get_session() as session:
        cfg = await ImportSettings.load(session)
        untranslated = (await session.execute(
            select(func.count(Post.id))
            .where(Post.translated_source_lang.is_(None))
            .where(or_(
                Post.post_title.is_not(None), Post.description.is_not(None),
            ))
        )).scalar_one()
        # Live progress: is a sweep running now, and what did the last one do?
        # (run-until-done re-enqueues itself, so `active` stays true across a
        # bulk re-translate; `last_run` surfaces a completed run's outcome.)
        active = (await session.execute(
            select(func.count(TaskRun.id))
            .where(TaskRun.task_name.in_(translation_tasks))
            .where(TaskRun.status == "running")
        )).scalar_one()
        last = (await session.execute(
            select(TaskRun.task_name, TaskRun.status, TaskRun.finished_at)
            .where(TaskRun.task_name.in_(translation_tasks))
            .where(TaskRun.finished_at.is_not(None))
            .order_by(TaskRun.finished_at.desc())
            .limit(1)
        )).first()
    base_url = (cfg.interpreter_base_url or "").strip()
    healthy = await asyncio.to_thread(ic.health, base_url) if base_url else False
    return jsonify({
        "enabled": cfg.translation_enabled,
        "base_url_set": bool(base_url),
        "healthy": healthy,
        "untranslated_count": int(untranslated),
        "active": int(active) > 0,
        "last_run": {
            "task": last[0].rsplit(".", 1)[-1],
            "status": last[1],
            "finished_at": last[2].isoformat() if last[2] else None,
        } if last else None,
    })


@settings_bp.route("/settings/translation/test", methods=["POST"])
async def translation_test():
    """On-demand reachability check for a GIVEN Interpreter base URL (the Settings
    'Test connection' button) — pings /v1/health without saving, so the operator
    can verify a URL before enabling. Health runs in a thread (sync client)."""
    body = await request.get_json()
    base_url = ""
    if isinstance(body, dict):
        base_url = (body.get("base_url") or "").strip()
    healthy = await asyncio.to_thread(ic.health, base_url) if base_url else False
    return jsonify({"healthy": healthy})


@settings_bp.route("/settings/translation/probe", methods=["POST"])
async def translation_probe():
    """Diagnostic for the Settings 'Test translation' box: translate a pasted
    snippet WITHOUT saving anything, returning what Interpreter *detected*
    (language + confidence) alongside the result. Lets the operator see why a
    given string was (mis-)detected — e.g. a short English title flagged as
    another language — so a detection guard can be tuned from real numbers.
    Read-only: no post is touched. Uses the currently-saved base URL + target."""
    body = await request.get_json(silent=True)
    body = body if isinstance(body, dict) else {}
    text = (body.get("text") or "").strip()
    if not text:
        return jsonify({"error": "provide text to translate"}), 400
    async with get_session() as session:
        cfg = await ImportSettings.load(session)
    base_url = (cfg.interpreter_base_url or "").strip()
    if not base_url:
        return jsonify({"error": "no Interpreter base URL is set"}), 400
    target = (cfg.translation_target_lang or "en").strip() or "en"
    try:
        res = await asyncio.to_thread(
            ic.translate, [text], base_url=base_url, target=target,
        )
    except ic.InterpreterUnavailable as e:
        return jsonify({"error": f"Interpreter unavailable: {e}"}), 503
    except ic.InterpreterBadRequest as e:
        return jsonify({"error": f"Interpreter rejected the request: {e}"}), 400
    translations = res.get("translations") or []
    return jsonify({
        "target": target,
        "detected_lang": res.get("detected_lang"),
        "detected_confidence": res.get("detected_confidence"),
        "engine": res.get("engine"),
        "engine_version": res.get("engine_version"),
        "translated": translations[0] if translations else None,
    })


@settings_bp.route("/settings/translation/run", methods=["POST"])
async def translation_run():
    """Enqueue the translate sweep now (the Settings 'Translate now' button).
    Runs in drain mode — run-until-done — so one press chases the whole
    untranslated backlog to zero rather than a single 300-post chunk."""
    async with get_session() as session:
        cfg = await ImportSettings.load(session)
    if not cfg.translation_enabled or not (cfg.interpreter_base_url or "").strip():
        return jsonify(
            {"error": "translation is disabled or no base URL is set"}
        ), 400
    from ..tasks.translation import translate_posts

    r = translate_posts.delay(drain=True)
    return jsonify({"celery_task_id": r.id}), 202


@settings_bp.route("/settings/translation/retranslate", methods=["POST"])
async def translation_retranslate():
    """Re-translate stored translations after a model change (m146). Body:
    ``{"artist_id": <int>}`` aims at one artist; ``{"all": true}`` re-runs every
    artist. ``all`` must be explicit so an empty/typo body can't wipe everything.
    Clears the scoped translations and enqueues the run-until-done retranslate
    sweep (the Interpreter cache re-translates on a changed model, is cache-fast
    otherwise). Same enabled + base-URL guard as 'Translate now'."""
    body = await request.get_json(silent=True)
    body = body if isinstance(body, dict) else {}
    artist_id = body.get("artist_id")
    do_all = bool(body.get("all"))
    if artist_id is None and not do_all:
        return jsonify(
            {"error": "provide artist_id, or all=true to re-translate everything"}
        ), 400
    if artist_id is not None:
        try:
            artist_id = int(artist_id)
        except (TypeError, ValueError):
            return jsonify({"error": "artist_id must be an integer"}), 400

    async with get_session() as session:
        cfg = await ImportSettings.load(session)
    if not cfg.translation_enabled or not (cfg.interpreter_base_url or "").strip():
        return jsonify(
            {"error": "translation is disabled or no base URL is set"}
        ), 400
    from ..tasks.translation import retranslate_posts

    # artist_id wins when both are sent; otherwise all=true → None (every artist).
    artist_ids = [artist_id] if artist_id is not None else None
    r = retranslate_posts.delay(artist_ids=artist_ids)
    return jsonify({"celery_task_id": r.id}), 202
