"""Tests for news subscription run helpers and the active/due predicates.

These cover the consolidation of three previously-duplicated subscription run
paths (manual run-now, the overdue sweep, the background scheduler) onto:

* ``NewsSubscription.active_filter`` / ``due_filter`` -- the single authoritative
  definition of "active" / "due", keyed on the ``status`` column.
* ``build_subscription_request_data`` -- the shared /research/api/start payload.
* ``advance_refresh_schedule`` -- the shared refresh-timestamp arithmetic.

The predicate tests pin the bug they fixed: a subscription whose ``status`` is
"paused" must never be treated as active/due, even though its legacy
``is_active`` column may still read ``True`` (create_subscription leaves the
column at its default).
"""

from datetime import datetime, timezone, timedelta

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from local_deep_research.database.models.news import NewsSubscription
from local_deep_research.news.subscription_runner import (
    advance_refresh_schedule,
    advance_refresh_schedule_by_id,
    build_subscription_request_data,
    mark_subscription_due_by_id,
)


@pytest.fixture
def db_session():
    """In-memory SQLite session with just the news_subscriptions table."""
    engine = create_engine("sqlite:///:memory:")
    NewsSubscription.__table__.create(engine)
    session = sessionmaker(bind=engine)()
    yield session
    session.close()


def _make_sub(session, sub_id, **overrides):
    defaults = dict(
        id=sub_id,
        query_or_topic="some query",
        subscription_type="topic",
        status="active",
        refresh_interval_minutes=60,
    )
    defaults.update(overrides)
    sub = NewsSubscription(**defaults)
    session.add(sub)
    session.commit()
    return sub


class TestActiveAndDueFilters:
    def test_paused_with_stale_is_active_column_is_not_due(self, db_session):
        """A paused subscription must not be due even if is_active is True.

        This is the bug the consolidation fixed: create_subscription writes
        only status, leaving the is_active column at its default True, so
        filtering on is_active ran paused subscriptions.
        """
        now = datetime.now(timezone.utc)
        _make_sub(
            db_session,
            "paused",
            status="paused",
            is_active=True,  # stale mirror, must be ignored
            next_refresh=now - timedelta(hours=1),
        )

        due = (
            db_session.query(NewsSubscription)
            .filter(NewsSubscription.due_filter(now))
            .all()
        )
        active = (
            db_session.query(NewsSubscription)
            .filter(NewsSubscription.active_filter())
            .all()
        )
        assert due == []
        assert active == []

    def test_active_overdue_is_due(self, db_session):
        now = datetime.now(timezone.utc)
        _make_sub(db_session, "a", next_refresh=now - timedelta(minutes=1))
        due = (
            db_session.query(NewsSubscription)
            .filter(NewsSubscription.due_filter(now))
            .all()
        )
        assert [s.id for s in due] == ["a"]

    def test_active_with_future_refresh_is_not_due(self, db_session):
        now = datetime.now(timezone.utc)
        _make_sub(db_session, "future", next_refresh=now + timedelta(hours=1))
        due = (
            db_session.query(NewsSubscription)
            .filter(NewsSubscription.due_filter(now))
            .all()
        )
        assert due == []

    def test_active_with_null_refresh_is_not_due(self, db_session):
        now = datetime.now(timezone.utc)
        _make_sub(db_session, "nullref", next_refresh=None)
        due = (
            db_session.query(NewsSubscription)
            .filter(NewsSubscription.due_filter(now))
            .all()
        )
        assert due == []

    def test_active_filter_ignores_refresh_timing(self, db_session):
        """active_filter is about on/off, independent of next_refresh."""
        now = datetime.now(timezone.utc)
        _make_sub(db_session, "future", next_refresh=now + timedelta(hours=1))
        _make_sub(db_session, "nullref", next_refresh=None)
        _make_sub(db_session, "paused", status="paused", next_refresh=None)
        active = (
            db_session.query(NewsSubscription)
            .filter(NewsSubscription.active_filter())
            .all()
        )
        assert sorted(s.id for s in active) == ["future", "nullref"]


class TestBuildSubscriptionRequestData:
    def test_replaces_date_placeholder_only_in_query(self):
        data = build_subscription_request_data(
            query_template="news on YYYY-MM-DD please",
            current_date="2026-06-10",
            triggered_by="manual",
            subscription_id="sub-1",
            title="My sub",
        )
        assert data["query"] == "news on 2026-06-10 please"
        # original_query keeps the placeholder; processed_query has the date
        assert data["metadata"]["original_query"] == "news on YYYY-MM-DD please"
        assert (
            data["metadata"]["processed_query"] == "news on 2026-06-10 please"
        )
        assert data["metadata"]["news_date"] == "2026-06-10"

    def test_news_metadata_block(self):
        data = build_subscription_request_data(
            query_template="q",
            current_date="2026-06-10",
            triggered_by="overdue_check",
            subscription_id=42,
            title="T",
        )
        meta = data["metadata"]
        assert meta["is_news_search"] is True
        assert meta["search_type"] == "news_analysis"
        assert meta["display_in"] == "news_feed"
        assert meta["triggered_by"] == "overdue_check"
        # subscription_id is always stringified regardless of input type
        assert meta["subscription_id"] == "42"
        assert meta["title"] == "T"
        assert data["mode"] == "quick"

    def test_unset_model_passed_through_as_falsy(self):
        """Unset provider/model stay falsy so the backend uses user settings."""
        data = build_subscription_request_data(
            query_template="q",
            current_date="2026-06-10",
            triggered_by="manual",
            subscription_id="s",
            model_provider=None,
            model=None,
            search_strategy=None,
        )
        assert data["model_provider"] is None
        assert data["model"] is None
        # strategy falls back to the news default
        assert data["strategy"] == "news_aggregation"

    def test_optional_fields_included_only_when_set(self):
        without = build_subscription_request_data(
            query_template="q",
            current_date="2026-06-10",
            triggered_by="manual",
            subscription_id="s",
        )
        assert "search_engine" not in without
        assert "custom_endpoint" not in without

        with_optional = build_subscription_request_data(
            query_template="q",
            current_date="2026-06-10",
            triggered_by="manual",
            subscription_id="s",
            search_engine="searxng",
            custom_endpoint="https://example.test",
            search_strategy="focused-iteration",
        )
        assert with_optional["search_engine"] == "searxng"
        assert with_optional["custom_endpoint"] == "https://example.test"
        assert with_optional["strategy"] == "focused-iteration"

    def test_empty_title_normalized_to_none(self):
        data = build_subscription_request_data(
            query_template="q",
            current_date="2026-06-10",
            triggered_by="manual",
            subscription_id="s",
            title="",
        )
        assert data["metadata"]["title"] is None


class TestAdvanceRefreshSchedule:
    def test_sets_last_and_next_refresh(self):
        now = datetime(2026, 6, 10, 12, 0, tzinfo=timezone.utc)
        sub = NewsSubscription(
            id="s",
            query_or_topic="q",
            subscription_type="topic",
            refresh_interval_minutes=90,
        )
        advance_refresh_schedule(sub, now)
        assert sub.last_refresh == now
        assert sub.next_refresh == now + timedelta(minutes=90)

    def test_defaults_now_to_current_utc(self):
        sub = NewsSubscription(
            id="s",
            query_or_topic="q",
            subscription_type="topic",
            refresh_interval_minutes=30,
        )
        before = datetime.now(timezone.utc)
        advance_refresh_schedule(sub)
        after = datetime.now(timezone.utc)
        assert before <= sub.last_refresh <= after
        assert sub.next_refresh == sub.last_refresh + timedelta(minutes=30)


class TestAdvanceRefreshScheduleById:
    def test_advances_existing_subscription(self, db_session):
        sub = _make_sub(db_session, "s1", next_refresh=None)
        before = datetime.now(timezone.utc)

        assert advance_refresh_schedule_by_id(db_session, "s1") is True
        assert sub.last_refresh >= before
        assert sub.next_refresh == sub.last_refresh + timedelta(minutes=60)

    def test_missing_subscription_returns_false(self, db_session):
        assert advance_refresh_schedule_by_id(db_session, "nope") is False

    def test_non_string_id_is_stringified(self, db_session):
        _make_sub(db_session, "42", next_refresh=None)
        # Callers pass ids from metadata dicts, which may be ints.
        assert advance_refresh_schedule_by_id(db_session, 42) is True


class TestMarkSubscriptionDueById:
    """The failure-path reset that undoes the spawn-time schedule advance.

    run_subscription_now advances next_refresh at spawn time so the scheduler
    will not double-run an in-flight subscription. If that run fails, the
    success-only completion advance never fires; without this reset the
    subscription would be hidden from the scheduler for a full interval.
    """

    def test_resets_next_refresh_to_now(self, db_session):
        # Simulate the spawn-time advance: next_refresh pushed far out.
        future = datetime.now(timezone.utc) + timedelta(hours=24)
        last = datetime.now(timezone.utc) - timedelta(minutes=1)
        _make_sub(db_session, "s1", next_refresh=future, last_refresh=last)

        before = datetime.now(timezone.utc)
        assert mark_subscription_due_by_id(db_session, "s1") is True
        after = datetime.now(timezone.utc)

        sub = (
            db_session.query(NewsSubscription)
            .filter(NewsSubscription.id == "s1")
            .first()
        )
        # next_refresh reset to ~now so due_filter(now) selects it again.
        assert before <= sub.next_refresh <= after
        # last_refresh is the last *successful* refresh and must be untouched.
        assert sub.last_refresh == last

    def test_missing_subscription_returns_false(self, db_session):
        assert mark_subscription_due_by_id(db_session, "nope") is False

    def test_reset_makes_subscription_due_again(self, db_session):
        """End-to-end: a spawn-advanced sub is not due, then is after reset."""
        future = datetime.now(timezone.utc) + timedelta(hours=24)
        _make_sub(db_session, "s1", next_refresh=future)
        now = datetime.now(timezone.utc)

        not_due = (
            db_session.query(NewsSubscription)
            .filter(NewsSubscription.due_filter(now))
            .all()
        )
        assert not_due == []

        mark_subscription_due_by_id(db_session, "s1")
        db_session.commit()

        due = (
            db_session.query(NewsSubscription)
            .filter(NewsSubscription.due_filter(datetime.now(timezone.utc)))
            .all()
        )
        assert [s.id for s in due] == ["s1"]

    def test_non_string_id_is_stringified(self, db_session):
        _make_sub(db_session, "7", next_refresh=None)
        assert mark_subscription_due_by_id(db_session, 7) is True
