"""Remove the 'auto' and 'parallel' meta search engines from stored data.

Background
==========
The ``auto`` (MetaSearchEngine, aliased ``meta``) and ``parallel`` /
``parallel_scientific`` (ParallelSearchEngine) meta engines were removed:
the langgraph-agent strategy (the default) selects engines dynamically per
tool call, which made the LLM-based meta-pickers redundant. The search
engine factory fails closed on unknown engine names, so any stored
reference to a removed engine would raise at research time.

What this migration does
========================
1. ``settings``: rewrites ``search.tool`` values naming a removed engine
   to ``"searxng"`` (the canonical default in ``default_settings.json``).
2. ``settings``: deletes orphaned per-engine setting rows
   (``search.engine.auto.*`` and ``search.engine.web.parallel.*``).
3. ``news_subscriptions``: sets ``search_engine`` to NULL where it names a
   removed engine — the scheduler treats a falsy value as "use the user's
   default search tool", which matches what "auto" meant on a
   subscription.
4. ``queued_researches``: queued rows are replayed after a restart, so
   ``settings_snapshot`` JSON is rewritten (submission.search_engine and
   any embedded ``search.tool`` setting) to ``"searxng"``.
5. ``benchmark_runs`` / ``benchmark_configs``: ``search_config`` JSON
   ``search_tool`` values are rewritten so saved configs and resumable
   runs keep working.

Storage note: the settings ``value`` column uses SQLAlchemy's ``JSON``
type, which stores strings as quoted TEXT — the WHERE clause matches the
JSON-encoded form (``'"auto"'`` with quotes). See migration 0009 for the
same pattern.

Revision ID: 0013
Revises: 0012
Create Date: 2026-06-12
"""

from __future__ import annotations

import json

import sqlalchemy as sa
from alembic import op
from loguru import logger
from sqlalchemy import inspect

revision = "0013"
down_revision = "0012"
branch_labels = None
depends_on = None

REMOVED_ENGINES = ("auto", "meta", "parallel", "parallel_scientific")
REMOVED_ENGINES_JSON = tuple(json.dumps(name) for name in REMOVED_ENGINES)
REPLACEMENT_ENGINE = "searxng"
REPLACEMENT_ENGINE_JSON = json.dumps(REPLACEMENT_ENGINE)

ORPHAN_KEY_PREFIXES = ("search.engine.auto.", "search.engine.web.parallel.")


def _rewrite_search_tool_setting(conn) -> None:
    result = conn.execute(
        sa.text(
            "UPDATE settings SET value = :new_value "
            "WHERE key = 'search.tool' AND value IN :old_values"
        ).bindparams(sa.bindparam("old_values", expanding=True)),
        {
            "new_value": REPLACEMENT_ENGINE_JSON,
            "old_values": list(REMOVED_ENGINES_JSON),
        },
    )
    if result.rowcount:
        logger.info(
            "Migrated search.tool from a removed meta engine to {!r} "
            "({} row(s)).",
            REPLACEMENT_ENGINE,
            result.rowcount,
        )


def _delete_orphan_engine_settings(conn) -> None:
    for prefix in ORPHAN_KEY_PREFIXES:
        result = conn.execute(
            sa.text("DELETE FROM settings WHERE key LIKE :prefix"),
            {"prefix": prefix + "%"},
        )
        if result.rowcount:
            logger.info(
                "Deleted {} orphaned setting row(s) under {!r}.",
                result.rowcount,
                prefix,
            )


def _null_news_subscription_engines(conn) -> None:
    result = conn.execute(
        sa.text(
            "UPDATE news_subscriptions SET search_engine = NULL "
            "WHERE search_engine IN :old_values"
        ).bindparams(sa.bindparam("old_values", expanding=True)),
        {"old_values": list(REMOVED_ENGINES)},
    )
    if result.rowcount:
        logger.info(
            "Cleared removed meta engine from {} news subscription(s); "
            "they fall back to the user's default search tool.",
            result.rowcount,
        )


def _rewrite_snapshot(snapshot: dict) -> bool:
    """Rewrite removed engine names inside a queued-research snapshot.

    Handles both the new structure ({"submission": {...},
    "settings_snapshot": {...}}) and the legacy flat structure where the
    submission params are the snapshot itself.
    """
    changed = False

    submission = (
        snapshot.get("submission")
        if isinstance(snapshot.get("submission"), dict)
        else snapshot
    )
    if submission.get("search_engine") in REMOVED_ENGINES:
        submission["search_engine"] = REPLACEMENT_ENGINE
        changed = True

    complete = snapshot.get("settings_snapshot")
    if isinstance(complete, dict) and "search.tool" in complete:
        entry = complete["search.tool"]
        if isinstance(entry, dict):
            if entry.get("value") in REMOVED_ENGINES:
                entry["value"] = REPLACEMENT_ENGINE
                changed = True
        elif entry in REMOVED_ENGINES:
            complete["search.tool"] = REPLACEMENT_ENGINE
            changed = True

    return changed


def _rewrite_queued_research_snapshots(conn) -> None:
    rows = conn.execute(
        sa.text("SELECT id, settings_snapshot FROM queued_researches")
    ).fetchall()

    rewritten = 0
    for row_id, raw in rows:
        if not raw:
            continue
        snapshot = json.loads(raw) if isinstance(raw, str) else raw
        if not isinstance(snapshot, dict):
            continue
        if _rewrite_snapshot(snapshot):
            conn.execute(
                sa.text(
                    "UPDATE queued_researches SET settings_snapshot = :snap "
                    "WHERE id = :id"
                ),
                {"snap": json.dumps(snapshot), "id": row_id},
            )
            rewritten += 1

    if rewritten:
        logger.info(
            "Rewrote removed meta engine in {} queued research row(s).",
            rewritten,
        )


def _rewrite_benchmark_search_configs(conn, table: str) -> None:
    # ``table`` is always a hardcoded literal ("benchmark_runs" /
    # "benchmark_configs"), never user input — so this f-string SQL is a false
    # positive. Bearer honors the directive ONLY on its own line directly above
    # the statement with the rule id alone; a same-line directive, or any
    # trailing prose after the rule id, is silently ignored.
    # bearer:disable python_lang_sql_injection
    rows = conn.execute(
        sa.text(f"SELECT id, search_config FROM {table}")  # noqa: S608
    ).fetchall()

    rewritten = 0
    for row_id, raw in rows:
        if not raw:
            continue
        config = json.loads(raw) if isinstance(raw, str) else raw
        if not isinstance(config, dict):
            continue
        if config.get("search_tool") in REMOVED_ENGINES:
            config["search_tool"] = REPLACEMENT_ENGINE
            # ``table`` is always a hardcoded literal ("benchmark_runs" /
            # "benchmark_configs"), never user input — so this f-string SQL is a
            # false positive. Bearer honors the directive ONLY on its own line
            # directly above the statement with the rule id alone; a same-line
            # directive, or any trailing prose after the rule id, is ignored.
            # bearer:disable python_lang_sql_injection
            conn.execute(
                sa.text(
                    f"UPDATE {table} SET search_config = :config "  # noqa: S608
                    "WHERE id = :id"
                ),
                {"config": json.dumps(config), "id": row_id},
            )
            rewritten += 1

    if rewritten:
        logger.info(
            "Rewrote removed meta engine in {} {} row(s).",
            rewritten,
            table,
        )


def upgrade() -> None:
    conn = op.get_bind()
    inspector = inspect(conn)

    if inspector.has_table("settings"):
        _rewrite_search_tool_setting(conn)
        _delete_orphan_engine_settings(conn)

    if inspector.has_table("news_subscriptions"):
        _null_news_subscription_engines(conn)

    if inspector.has_table("queued_researches"):
        _rewrite_queued_research_snapshots(conn)

    for table in ("benchmark_runs", "benchmark_configs"):
        if inspector.has_table(table):
            _rewrite_benchmark_search_configs(conn, table)


def downgrade() -> None:
    """No-op.

    The removed engines no longer exist in the codebase, so restoring
    references to them would only recreate broken state. The deleted
    orphan settings rows are re-seeded from defaults files by older code
    versions if ever needed.
    """
