"""Remove the 'mcp'/'agentic' search strategy from stored data.

Background
==========
``MCPSearchStrategy`` (factory keys ``mcp`` / ``agentic``) was removed: the
``langgraph-agent`` strategy (the default) is a near functional superset and
the parallel ReAct loop was redundant. The strategy factory now routes the
``mcp``/``agentic`` aliases to ``langgraph-agent`` at runtime, but persisted
references should be rewritten so the deprecation warning does not fire
forever and the orphaned ``mcp.servers`` setting is cleaned up.

What this migration does
========================
1. ``settings``: rewrites ``search.search_strategy`` values of ``mcp`` /
   ``agentic`` to ``langgraph-agent``.
2. ``settings``: deletes the orphaned ``mcp.servers`` row (its only consumer,
   the MCP strategy, is gone).
3. ``news_subscriptions``: sets ``search_strategy`` to NULL where it names a
   removed strategy — the subscription runner treats a falsy value as the
   default ``news_aggregation`` strategy (``subscription_runner.py``:
   ``search_strategy or "news_aggregation"``), which is the desired fallback.
4. ``queued_researches``: queued rows are replayed after a restart, so the
   ``settings_snapshot`` JSON is rewritten (both the top-level
   ``submission.strategy`` and any embedded ``search.search_strategy``
   setting) to ``langgraph-agent``.
5. ``benchmark_runs`` / ``benchmark_configs``: ``search_config`` JSON
   ``search_strategy`` 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 (``'"mcp"'`` with quotes). The lightweight ``sa.table``
definitions below declare these columns as **untyped** on purpose: an untyped
column reads/writes the raw stored value verbatim, whereas attaching
``sa.JSON`` would re-encode the bound value and corrupt it. See migration 0013
for the same overall pattern.

Revision ID: 0018
Revises: 0017
Create Date: 2026-06-13
"""

from __future__ import annotations

import json

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

revision = "0018"
down_revision = "0017"
branch_labels = None
depends_on = None

REMOVED_STRATEGIES = ("mcp", "agentic")
# JSON-encoded form for the ``settings.value`` JSON column (stored quoted).
REMOVED_STRATEGIES_JSON = tuple(json.dumps(name) for name in REMOVED_STRATEGIES)
REPLACEMENT_STRATEGY = "langgraph-agent"
REPLACEMENT_STRATEGY_JSON = json.dumps(REPLACEMENT_STRATEGY)

STRATEGY_SETTING_KEY = "search.search_strategy"
MCP_SERVERS_KEY = "mcp.servers"

# Untyped table definitions — raw stored values, no JSON re-encoding.
_settings = sa.table("settings", sa.column("key"), sa.column("value"))
_news = sa.table("news_subscriptions", sa.column("search_strategy"))
_queued = sa.table(
    "queued_researches", sa.column("id"), sa.column("settings_snapshot")
)
# The two benchmark tables share the same shape. They are declared here with
# literal names (rather than building ``sa.table(table_name, ...)`` from a
# passed-in string) so the migration never constructs a SQL identifier from a
# variable. The shared helper below still receives the table object as a
# parameter, which Bearer's taint analysis reports as a SQL-injection false
# positive on its ``conn.execute`` call — suppressed with a
# ``# bearer:disable python_lang_sql_injection`` directive placed on the line
# directly above the statement (the only placement Bearer honors).
_benchmark_runs = sa.table(
    "benchmark_runs", sa.column("id"), sa.column("search_config")
)
_benchmark_configs = sa.table(
    "benchmark_configs", sa.column("id"), sa.column("search_config")
)


def _rewrite_search_strategy_setting(conn) -> None:
    result = conn.execute(
        sa.update(_settings)
        .where(_settings.c.key == STRATEGY_SETTING_KEY)
        .where(_settings.c.value.in_(list(REMOVED_STRATEGIES_JSON)))
        .values(value=REPLACEMENT_STRATEGY_JSON)
    )
    if result.rowcount:
        logger.info(
            "Migrated {} setting row(s) from a removed strategy to {!r}.",
            result.rowcount,
            REPLACEMENT_STRATEGY,
        )


def _delete_mcp_servers_setting(conn) -> None:
    result = conn.execute(
        sa.delete(_settings).where(_settings.c.key == MCP_SERVERS_KEY)
    )
    if result.rowcount:
        logger.info(
            "Deleted {} orphaned 'mcp.servers' setting row(s).",
            result.rowcount,
        )


def _null_news_subscription_strategies(conn) -> None:
    result = conn.execute(
        sa.update(_news)
        .where(_news.c.search_strategy.in_(list(REMOVED_STRATEGIES)))
        .values(search_strategy=None)
    )
    if result.rowcount:
        logger.info(
            "Cleared a removed strategy from {} news subscription(s); "
            "they fall back to the user's default search strategy.",
            result.rowcount,
        )


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

    Handles both the 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("strategy") in REMOVED_STRATEGIES:
        submission["strategy"] = REPLACEMENT_STRATEGY
        changed = True

    complete = snapshot.get("settings_snapshot")
    if isinstance(complete, dict) and STRATEGY_SETTING_KEY in complete:
        entry = complete[STRATEGY_SETTING_KEY]
        if isinstance(entry, dict):
            if entry.get("value") in REMOVED_STRATEGIES:
                entry["value"] = REPLACEMENT_STRATEGY
                changed = True
        elif entry in REMOVED_STRATEGIES:
            complete[STRATEGY_SETTING_KEY] = REPLACEMENT_STRATEGY
            changed = True

    return changed


def _rewrite_queued_research_snapshots(conn) -> None:
    rows = conn.execute(
        sa.select(_queued.c.id, _queued.c.settings_snapshot)
    ).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.update(_queued)
                .where(_queued.c.id == row_id)
                .values(settings_snapshot=json.dumps(snapshot))
            )
            rewritten += 1

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


def _rewrite_benchmark_search_configs(conn, table) -> None:
    """Rewrite removed strategies in a benchmark table's ``search_config`` JSON.

    ``table`` is one of the module-level literal ``sa.table`` objects
    (``_benchmark_runs`` / ``_benchmark_configs``) — never a string and never
    external input — so the table/column identifiers are fixed literals that
    SQLAlchemy renders verbatim (and the UPDATE binds its value as a parameter);
    nothing user-derived reaches the SQL. Bearer's taint analysis nonetheless
    reports the ``table`` parameter flowing into ``conn.execute`` as SQL
    injection (a false positive). It is suppressed with a
    ``# bearer:disable python_lang_sql_injection`` directive on the line
    directly above the statement — the only placement Bearer honors (a
    same-line trailing directive is ignored). Bearer flags only the read; the
    write below carries the same directive defensively.
    """
    # ``table`` is a module-level literal sa.table (see docstring) — the Bearer
    # SQL-injection report on this read is a false positive.
    # bearer:disable python_lang_sql_injection
    rows = conn.execute(sa.select(table.c.id, table.c.search_config)).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_strategy") in REMOVED_STRATEGIES:
            config["search_strategy"] = REPLACEMENT_STRATEGY
            # Same module-level literal table object, no external input.
            # bearer:disable python_lang_sql_injection
            conn.execute(
                sa.update(table)
                .where(table.c.id == row_id)
                .values(search_config=json.dumps(config))
            )
            rewritten += 1

    if rewritten:
        logger.info(
            "Rewrote a removed strategy in {} {} row(s).",
            rewritten,
            table.name,
        )


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

    if inspector.has_table("settings"):
        _rewrite_search_strategy_setting(conn)
        _delete_mcp_servers_setting(conn)

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

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

    for table in (_benchmark_runs, _benchmark_configs):
        if inspector.has_table(table.name):
            _rewrite_benchmark_search_configs(conn, table)


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

    The removed strategy no longer exists in the codebase, so restoring
    references to it would only recreate broken state. The deleted
    ``mcp.servers`` row is re-seeded from defaults files by older code
    versions if ever needed.
    """
