"""Tests for migration 0018: remove the 'mcp'/'agentic' search strategy.

Pins the upgrade semantics:
- ``search.search_strategy`` rows naming a removed strategy (mcp / agentic)
  are rewritten to ``langgraph-agent``; concrete choices are preserved.
- The orphaned ``mcp.servers`` setting row is deleted.
- ``news_subscriptions.search_strategy`` is NULLed for removed strategies
  (falsy means "use the user's default strategy" in the scheduler).
- ``queued_researches.settings_snapshot`` JSON has both the top-level
  ``submission.strategy`` and the embedded ``search.search_strategy``
  rewritten, for the nested and the legacy flat snapshot structure.
- ``benchmark_runs`` / ``benchmark_configs`` ``search_config.search_strategy``
  is rewritten.
- The settings ``value`` column stores JSON text (``"mcp"`` with quotes); the
  rewritten value must be stored as ``"langgraph-agent"`` (single JSON
  encoding) — a raw-bytes assertion guards against double-encoding.
- Idempotency: re-running the upgrade is a no-op.
"""

import json

import pytest
from alembic import command
from sqlalchemy import create_engine, text

from local_deep_research.database.alembic_runner import (
    get_alembic_config,
)

REMOVED = ["mcp", "agentic"]
REPLACEMENT = "langgraph-agent"


def _run_upgrade_to(engine, revision):
    config = get_alembic_config(engine)
    with engine.begin() as conn:
        config.attributes["connection"] = conn
        command.upgrade(config, revision)


def _seed_setting(engine, key, value):
    with engine.begin() as conn:
        conn.execute(
            text(
                "INSERT INTO settings "
                "(key, value, type, name, ui_element, visible, editable) "
                "VALUES (:key, :value, 'search', :name, 'select', 1, 1)"
            ),
            {"key": key, "value": json.dumps(value), "name": key},
        )


def _read_setting(engine, key):
    with engine.begin() as conn:
        row = conn.execute(
            text("SELECT value FROM settings WHERE key = :key"),
            {"key": key},
        ).fetchone()
    if row is None:
        return None
    raw = row[0]
    return json.loads(raw) if isinstance(raw, str) else raw


def _read_setting_raw(engine, key):
    """Return the stored bytes of ``settings.value`` without decoding."""
    with engine.begin() as conn:
        row = conn.execute(
            text("SELECT value FROM settings WHERE key = :key"),
            {"key": key},
        ).fetchone()
    return None if row is None else row[0]


@pytest.fixture
def migrated_to_0017_engine(tmp_path):
    """Database fully migrated through 0017 (the revision before 0018)."""
    db_path = tmp_path / "test_0018.db"
    engine = create_engine(f"sqlite:///{db_path}")
    _run_upgrade_to(engine, "0017")
    yield engine
    engine.dispose()


class TestMigration0018Settings:
    @pytest.mark.parametrize("removed_value", REMOVED)
    def test_strategy_rewritten_to_langgraph_agent(
        self, migrated_to_0017_engine, removed_value
    ):
        engine = migrated_to_0017_engine
        _seed_setting(engine, "search.search_strategy", removed_value)

        _run_upgrade_to(engine, "0018")

        assert _read_setting(engine, "search.search_strategy") == REPLACEMENT

    @pytest.mark.parametrize("removed_value", REMOVED)
    def test_rewritten_value_stored_single_encoded(
        self, migrated_to_0017_engine, removed_value
    ):
        """The stored bytes must be ``"langgraph-agent"`` (single JSON
        encoding), not a double-encoded ``"\\"langgraph-agent\\""``."""
        engine = migrated_to_0017_engine
        _seed_setting(engine, "search.search_strategy", removed_value)

        _run_upgrade_to(engine, "0018")

        assert _read_setting_raw(
            engine, "search.search_strategy"
        ) == json.dumps(REPLACEMENT)

    @pytest.mark.parametrize(
        "concrete", ["source-based", "focused-iteration", "topic-organization"]
    )
    def test_concrete_strategy_preserved(
        self, migrated_to_0017_engine, concrete
    ):
        engine = migrated_to_0017_engine
        _seed_setting(engine, "search.search_strategy", concrete)

        _run_upgrade_to(engine, "0018")

        assert _read_setting(engine, "search.search_strategy") == concrete

    def test_mcp_servers_setting_deleted(self, migrated_to_0017_engine):
        engine = migrated_to_0017_engine
        _seed_setting(engine, "mcp.servers", [{"command": "npx"}])

        _run_upgrade_to(engine, "0018")

        assert _read_setting(engine, "mcp.servers") is None

    def test_upgrade_is_idempotent(self, migrated_to_0017_engine):
        engine = migrated_to_0017_engine
        _seed_setting(engine, "search.search_strategy", "mcp")

        _run_upgrade_to(engine, "0018")
        _run_upgrade_to(engine, "0018")

        assert _read_setting(engine, "search.search_strategy") == REPLACEMENT

    def test_missing_rows_are_a_clean_noop(self, migrated_to_0017_engine):
        engine = migrated_to_0017_engine

        _run_upgrade_to(engine, "0018")

        assert _read_setting(engine, "search.search_strategy") is None


class TestMigration0018NewsSubscriptions:
    def _seed_subscription(self, engine, sub_id, search_strategy):
        with engine.begin() as conn:
            conn.execute(
                text(
                    "INSERT INTO news_subscriptions "
                    "(id, subscription_type, query_or_topic, search_strategy) "
                    "VALUES (:id, 'search', 'test query', :ss)"
                ),
                {"id": sub_id, "ss": search_strategy},
            )

    def _read_subscription_strategy(self, engine, sub_id):
        with engine.begin() as conn:
            return conn.execute(
                text(
                    "SELECT search_strategy FROM news_subscriptions "
                    "WHERE id = :id"
                ),
                {"id": sub_id},
            ).fetchone()[0]

    def test_removed_strategy_nulled_concrete_preserved(
        self, migrated_to_0017_engine
    ):
        engine = migrated_to_0017_engine
        self._seed_subscription(engine, "sub-mcp", "mcp")
        self._seed_subscription(engine, "sub-agentic", "agentic")
        self._seed_subscription(engine, "sub-lg", "langgraph-agent")
        self._seed_subscription(engine, "sub-null", None)

        _run_upgrade_to(engine, "0018")

        assert self._read_subscription_strategy(engine, "sub-mcp") is None
        assert self._read_subscription_strategy(engine, "sub-agentic") is None
        assert (
            self._read_subscription_strategy(engine, "sub-lg")
            == "langgraph-agent"
        )
        assert self._read_subscription_strategy(engine, "sub-null") is None


class TestMigration0018QueuedResearches:
    def _seed_queued(self, engine, research_id, snapshot):
        with engine.begin() as conn:
            conn.execute(
                text(
                    "INSERT INTO queued_researches "
                    "(username, research_id, query, mode, settings_snapshot, "
                    "position) "
                    "VALUES ('tester', :rid, 'q', 'quick', :snap, 1)"
                ),
                {"rid": research_id, "snap": json.dumps(snapshot)},
            )

    def _read_snapshot(self, engine, research_id):
        with engine.begin() as conn:
            raw = conn.execute(
                text(
                    "SELECT settings_snapshot FROM queued_researches "
                    "WHERE research_id = :rid"
                ),
                {"rid": research_id},
            ).fetchone()[0]
        return json.loads(raw) if isinstance(raw, str) else raw

    def test_nested_submission_strategy_rewritten(
        self, migrated_to_0017_engine
    ):
        engine = migrated_to_0017_engine
        self._seed_queued(
            engine,
            "rid-nested",
            {
                "submission": {"strategy": "mcp", "model": "m"},
                "settings_snapshot": {
                    "search.search_strategy": {
                        "value": "agentic",
                        "type": "SEARCH",
                    }
                },
            },
        )

        _run_upgrade_to(engine, "0018")

        snap = self._read_snapshot(engine, "rid-nested")
        assert snap["submission"]["strategy"] == "langgraph-agent"
        assert (
            snap["settings_snapshot"]["search.search_strategy"]["value"]
            == "langgraph-agent"
        )

    def test_legacy_flat_snapshot_rewritten(self, migrated_to_0017_engine):
        engine = migrated_to_0017_engine
        self._seed_queued(
            engine, "rid-flat", {"strategy": "agentic", "model": "m"}
        )

        _run_upgrade_to(engine, "0018")

        snap = self._read_snapshot(engine, "rid-flat")
        assert snap["strategy"] == "langgraph-agent"

    def test_concrete_strategy_snapshot_untouched(
        self, migrated_to_0017_engine
    ):
        engine = migrated_to_0017_engine
        original = {
            "submission": {"strategy": "source-based"},
            "settings_snapshot": {"search.search_strategy": "source-based"},
        }
        self._seed_queued(engine, "rid-src", original)

        _run_upgrade_to(engine, "0018")

        assert self._read_snapshot(engine, "rid-src") == original


class TestMigration0018Benchmarks:
    def _seed_config(self, engine, name, search_strategy):
        search_config = json.dumps(
            {"search_strategy": search_strategy, "iterations": 2}
        )
        with engine.begin() as conn:
            conn.execute(
                text(
                    "INSERT INTO benchmark_configs "
                    "(name, config_hash, search_config, evaluation_config, "
                    "datasets_config, created_at, updated_at, is_default, "
                    "is_public, usage_count) "
                    "VALUES (:name, 'abcd1234', :sc, '{}', '{}', "
                    "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0, 0, 0)"
                ),
                {"name": name, "sc": search_config},
            )

    def _read_config_strategy(self, engine, name):
        with engine.begin() as conn:
            raw = conn.execute(
                text(
                    "SELECT search_config FROM benchmark_configs "
                    "WHERE name = :name"
                ),
                {"name": name},
            ).fetchone()[0]
        config = json.loads(raw) if isinstance(raw, str) else raw
        return config["search_strategy"]

    def test_benchmark_config_strategy_rewritten(self, migrated_to_0017_engine):
        engine = migrated_to_0017_engine
        self._seed_config(engine, "cfg-mcp", "mcp")
        self._seed_config(engine, "cfg-src", "source-based")

        _run_upgrade_to(engine, "0018")

        assert (
            self._read_config_strategy(engine, "cfg-mcp") == "langgraph-agent"
        )
        assert self._read_config_strategy(engine, "cfg-src") == "source-based"

    def _seed_run(self, engine, run_name, search_strategy):
        search_config = json.dumps(
            {"search_strategy": search_strategy, "iterations": 2}
        )
        with engine.begin() as conn:
            conn.execute(
                text(
                    "INSERT INTO benchmark_runs "
                    "(run_name, config_hash, query_hash_list, search_config, "
                    "evaluation_config, datasets_config, status, created_at, "
                    "updated_at, total_examples, completed_examples, "
                    "failed_examples) "
                    "VALUES (:name, 'h0', '[]', :sc, '{}', '{}', 'PENDING', "
                    "'2026-01-01 00:00:00', '2026-01-01 00:00:00', 0, 0, 0)"
                ),
                {"name": run_name, "sc": search_config},
            )

    def _read_run_strategy(self, engine, run_name):
        with engine.begin() as conn:
            raw = conn.execute(
                text(
                    "SELECT search_config FROM benchmark_runs "
                    "WHERE run_name = :name"
                ),
                {"name": run_name},
            ).fetchone()[0]
        config = json.loads(raw) if isinstance(raw, str) else raw
        return config["search_strategy"]

    def test_benchmark_run_strategy_rewritten(self, migrated_to_0017_engine):
        # upgrade() rewrites search_config in BOTH benchmark_runs and
        # benchmark_configs; this pins the benchmark_runs branch (the
        # benchmark_configs path is covered above), guarding against a
        # regression that breaks only one table binding.
        engine = migrated_to_0017_engine
        self._seed_run(engine, "run-mcp", "mcp")
        self._seed_run(engine, "run-src", "source-based")

        _run_upgrade_to(engine, "0018")

        assert self._read_run_strategy(engine, "run-mcp") == "langgraph-agent"
        assert self._read_run_strategy(engine, "run-src") == "source-based"


class TestMigration0018HeadAlignment:
    def test_0018_chains_correctly_to_0017(self):
        from alembic.config import Config
        from alembic.script import ScriptDirectory

        from local_deep_research.database.alembic_runner import (
            get_migrations_dir,
        )

        config = Config()
        config.set_main_option("script_location", str(get_migrations_dir()))
        script = ScriptDirectory.from_config(config)
        rev_0018 = script.get_revision("0018")
        assert rev_0018.down_revision == "0017"

    def test_head_revision_is_0018(self):
        # 0018 is now the latest revision. The head-alignment guard lives in
        # the newest migration's test file (moved here from the 0017 test
        # when this migration was rechained onto 0017 during the main merge).
        from local_deep_research.database.alembic_runner import (
            get_head_revision,
        )

        assert get_head_revision() == "0018"
