"""The ticker and the runner: claiming a firing, and not doing it twice.

`rule.py` is tested on its own in `test_schedule_rule.py`. What is tested here
is everything that goes wrong *around* a correct rule — which is the half that
fails silently. Nothing in this file needs an endpoint: the firing itself is
stubbed, because what is being checked is the bookkeeping, not the reply.
"""

from __future__ import annotations

from datetime import UTC, datetime, timedelta

import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select

from lembas.db.models import (
    ROLE_ASSISTANT,
    TARGET_CHAT,
    TARGET_REPORT,
    Chat,
    Message,
    Report,
    Schedule,
    User,
)
from lembas.services import settings_store
from lembas.services.schedule import clock, runner, ticker
from lembas.services.schedule import rule as rule_service


@pytest.fixture(autouse=True)
def scheduling_on(db):
    settings_store.update(db, {"enabled": True}, key=settings_store.SCHEDULES)
    return None


def _user(db) -> User:
    return db.scalars(select(User).order_by(User.created_at)).first()


def _schedule(db, *, chat_id: str = "", **kwargs) -> Schedule:
    fields = {
        "user_id": _user(db).id,
        "title": "A schedule",
        "instruction": "Do the thing.",
        "rule_json": rule_service.validate(
            {"start": "2026-01-01T00:00:00Z", "every": {"hours": 1}}
        ),
        "target": TARGET_CHAT,
        "chat_id": chat_id,
        "enabled": True,
        "next_fire_at": datetime(2026, 1, 1, tzinfo=UTC),
        **kwargs,
    }
    schedule = Schedule(**fields)
    db.add(schedule)
    db.commit()
    return schedule


# --- Claiming -------------------------------------------------------------------
@pytest.mark.anyio
async def test_a_firing_that_raises_still_moves_the_schedule_on(
    client: TestClient, db, registered, make_chat, monkeypatch
):
    """The single most important property here.

    Claim, commit, *then* fire. The other order is a hot loop: a schedule whose
    firing fails is retried every tick for ever, against whatever it was that
    failed — and the only symptom is load.
    """
    schedule = _schedule(db, chat_id=make_chat())

    async def explode(schedule_id, **kwargs):
        raise RuntimeError("the endpoint is down")

    monkeypatch.setattr(runner, "fire", explode)

    fired = await ticker.sweep(now=datetime(2026, 1, 1, 0, 30, tzinfo=UTC))
    assert fired == 1
    for task in list(ticker._FIRING):
        with pytest.raises(RuntimeError):
            await task

    db.refresh(schedule)
    assert schedule.next_fire_at is not None
    assert schedule.fired_count == 1


@pytest.mark.anyio
async def test_two_overlapping_sweeps_fire_once(
    client: TestClient, db, registered, make_chat, monkeypatch
):
    """A sweep can take minutes — a firing awaits a model. The lock is what
    stops the next tick claiming the same row again."""
    schedule = _schedule(db, chat_id=make_chat())
    calls: list[str] = []

    async def record(schedule_id, **kwargs):
        calls.append(schedule_id)

    monkeypatch.setattr(runner, "fire", record)

    now = datetime(2026, 1, 1, 0, 30, tzinfo=UTC)
    await ticker.sweep(now=now)
    await ticker.sweep(now=now)
    for task in list(ticker._FIRING):
        await task

    assert calls == [schedule.id]


@pytest.mark.anyio
async def test_exhaustion_disables_rather_than_looping(
    client: TestClient, db, registered, make_chat, monkeypatch
):
    """"Five times" meaning "for ever" is the failure. A rule with nothing left
    switches the row off, so it stops being examined at all."""
    monkeypatch.setattr(runner, "fire", lambda *a, **k: _noop())
    schedule = _schedule(
        db,
        chat_id=make_chat(),
        rule_json=rule_service.validate(
            {"start": "2026-01-01T00:00:00Z", "every": {"hours": 1}, "count": 1}
        ),
    )

    await ticker.sweep(now=datetime(2026, 1, 1, 0, 30, tzinfo=UTC))
    for task in list(ticker._FIRING):
        await task

    db.refresh(schedule)
    assert schedule.fired_count == 1
    assert schedule.enabled is False
    assert schedule.next_fire_at is None


@pytest.mark.anyio
async def test_a_missed_week_fires_once(
    client: TestClient, db, registered, make_chat, monkeypatch
):
    """The host was off. It comes back owing one run, not a hundred and
    sixty-eight — and the next one is measured from now."""
    calls: list[str] = []

    async def record(schedule_id, **kwargs):
        calls.append(schedule_id)

    monkeypatch.setattr(runner, "fire", record)
    schedule = _schedule(db, chat_id=make_chat())

    now = datetime(2026, 1, 8, 0, 30, tzinfo=UTC)
    await ticker.sweep(now=now)
    for task in list(ticker._FIRING):
        await task

    assert len(calls) == 1
    db.refresh(schedule)
    assert schedule.fired_count == 1
    assert schedule.next_fire_at.replace(tzinfo=UTC) > now


@pytest.mark.anyio
async def test_one_unreadable_row_does_not_stop_the_sweep(
    client: TestClient, db, registered, make_chat, monkeypatch
):
    """A ticker that dies on one bad row stops every schedule on the instance,
    and nothing anywhere says so."""
    calls: list[str] = []

    async def record(schedule_id, **kwargs):
        calls.append(schedule_id)

    monkeypatch.setattr(runner, "fire", record)

    broken = _schedule(db, chat_id=make_chat(), title="Broken")
    healthy = _schedule(db, chat_id=make_chat(), title="Healthy")

    real_advance = rule_service.advance

    def selective(rule, **kwargs):
        if rule.get("_broken"):
            raise ValueError("unreadable")
        return real_advance(rule, **kwargs)

    broken.rule_json = {**broken.rule_json, "_broken": True}
    db.commit()
    monkeypatch.setattr(rule_service, "advance", selective)

    await ticker.sweep(now=datetime(2026, 1, 1, 0, 30, tzinfo=UTC))
    for task in list(ticker._FIRING):
        await task

    assert calls == [healthy.id]
    db.refresh(broken)
    assert broken.enabled is False
    assert broken.last_error


def test_a_deleted_account_takes_its_schedules_with_it(
    client: TestClient, db, registered, make_chat
):
    """`user_id` is a real ForeignKey with CASCADE — unlike `chat_id`, which is
    a plain id because `migrations.py` cannot add a REFERENCES clause to a table
    that already exists. So an orphaned schedule cannot be reached at all, and
    the owner check in the sweep is a guard rather than a path.

    Worth pinning as the *reason* that guard looks unreachable: somebody
    removing it would be right about today and wrong the moment the column
    convention changes.
    """
    _schedule(db, chat_id=make_chat())
    owner = _user(db)

    db.delete(owner)
    db.commit()
    # The cascade is enforced by SQLite, not by an ORM relationship, so the
    # session's identity map still holds the row it was told about. Ask the
    # database rather than the cache.
    db.expunge_all()

    assert db.scalars(select(Schedule)).all() == []


@pytest.mark.anyio
async def test_nothing_fires_while_scheduling_is_switched_off(
    client: TestClient, db, registered, make_chat, monkeypatch
):
    """The instance switch is a switch, not a suggestion. Checked in the sweep
    rather than at the routes, so a row created while it was on does not go on
    firing after it is turned off."""
    calls: list[str] = []
    monkeypatch.setattr(runner, "fire", lambda sid, **k: calls.append(sid) or _noop())
    _schedule(db, chat_id=make_chat())
    settings_store.update(db, {"enabled": False}, key=settings_store.SCHEDULES)

    assert await ticker.sweep(now=datetime(2026, 1, 1, 0, 30, tzinfo=UTC)) == 0
    assert calls == []


# --- The runner -----------------------------------------------------------------
async def _noop() -> None:
    return None


@pytest.mark.anyio
async def test_a_firing_writes_a_machine_turn_and_starts_a_reply(
    client: TestClient, db, registered, make_chat
):
    """The role stays `user` — `build_messages` needs one there and `_inject`
    sends a queued turn verbatim. `machine` is what stops the transcript
    claiming the reader typed it."""
    chat_id = make_chat()
    schedule = _schedule(db, chat_id=chat_id)

    await runner.fire(schedule.id)

    turns = list(
        db.scalars(select(Message).where(Message.chat_id == chat_id).order_by(Message.created_at))
    )
    assert turns[0].role == "user"
    assert turns[0].machine is True
    assert "started by a schedule" in turns[0].content
    assert "Do the thing." in turns[0].content
    # And a reply was opened for it.
    assert turns[-1].role == ROLE_ASSISTANT
    assert turns[-1].complete is False


@pytest.mark.anyio
async def test_a_deleted_chat_switches_the_schedule_off(
    client: TestClient, db, registered, make_chat
):
    """Rather than firing into nothing on every tick from now on — which is a
    schedule that looks alive and produces nothing."""
    schedule = _schedule(db, chat_id="nosuchchat")

    await runner.fire(schedule.id)

    db.refresh(schedule)
    assert schedule.enabled is False
    assert "no longer exists" in schedule.last_error


@pytest.mark.anyio
async def test_a_backlog_skips_rather_than_queues_for_ever(
    client: TestClient, db, registered, make_chat
):
    """`_drain` takes one queued turn per reply, so a schedule firing faster
    than its chat can answer would build a backlog that outlives the day that
    caused it."""
    chat_id = make_chat()
    chat = db.get(Chat, chat_id)
    for index in range(5):
        db.add(
            Message(chat_id=chat.id, role="user", content=f"waiting {index}", queued=True)
        )
    db.commit()
    schedule = _schedule(db, chat_id=chat_id)

    await runner.fire(schedule.id)

    db.refresh(schedule)
    assert "previous run was still going" in schedule.last_error
    assert schedule.claimed_at is None


@pytest.mark.anyio
async def test_run_now_does_not_consume_the_scheduled_run(
    client: TestClient, db, registered, make_chat
):
    """Testing a schedule must not skip the run it was testing. Advancing is the
    ticker's job and nothing else's."""
    schedule = _schedule(db, chat_id=make_chat())
    before = clock.as_utc(schedule.next_fire_at)
    fired_before = schedule.fired_count

    await runner.run_now(schedule.id)

    db.refresh(schedule)
    # Through `as_utc` on both sides: a row read back from SQLite is naive while
    # one still in the session keeps its tzinfo, and comparing the two raises.
    assert clock.as_utc(schedule.next_fire_at) == before
    assert schedule.fired_count == fired_before


def _finished(db, chat_id: str, text: str) -> Message:
    """A reply that has already been written.

    `deliver` is tested against this rather than against the output of
    `runner.fire`, because `fire` starts a real generation — which, with no
    endpoint configured, races the test to write the same row. The delivery's
    job begins at a finished message, so that is what it is handed.
    """
    message = Message(
        chat_id=chat_id, role=ROLE_ASSISTANT, content=text, complete=True, model_id="m"
    )
    db.add(message)
    db.commit()
    return message


@pytest.mark.anyio
async def test_a_report_target_files_the_reply(
    client: TestClient, db, registered, make_chat
):
    chat_id = make_chat()
    schedule = _schedule(db, chat_id=chat_id, target=TARGET_REPORT, title="Daily news")
    assistant = _finished(db, chat_id, "Three things happened.")

    await runner.deliver(
        schedule.id, assistant.id, since=datetime(2020, 1, 1, tzinfo=UTC)
    )

    filed = db.scalars(select(Report)).all()
    assert [r.title for r in filed] == ["Daily news"]
    assert filed[0].body == "Three things happened."
    assert filed[0].schedule_id == schedule.id
    assert filed[0].unread is True


@pytest.mark.anyio
async def test_a_run_that_produced_nothing_still_files_something(
    client: TestClient, db, registered, make_chat
):
    """A scheduled report that silently did not appear is indistinguishable
    from a schedule that never fired. So the failure is filed as a report."""
    chat_id = make_chat()
    schedule = _schedule(db, chat_id=chat_id, target=TARGET_REPORT, title="Daily news")
    broken = Message(chat_id=chat_id, role=ROLE_ASSISTANT, content="", complete=True,
                     error="the endpoint refused")
    db.add(broken)
    db.commit()

    await runner.deliver(schedule.id, broken.id, since=datetime(2020, 1, 1, tzinfo=UTC))

    filed = db.scalars(select(Report)).all()
    assert len(filed) == 1
    assert filed[0].error


@pytest.mark.anyio
async def test_a_report_the_model_filed_itself_is_not_duplicated(
    client: TestClient, db, registered, make_chat
):
    """`report_write` during the run *is* the report. Filing the reply beside it
    would put two of everything in the feed."""
    from lembas.services import reports as reports_service

    chat_id = make_chat()
    schedule = _schedule(db, chat_id=chat_id, target=TARGET_REPORT)
    began = datetime.now(tz=UTC) - timedelta(seconds=5)
    reports_service.create(
        db, owner=_user(db), title="Filed by the model", body="...",
        source="chat", source_id=chat_id,
    )
    assistant = _finished(db, chat_id, "Also this.")

    await runner.deliver(schedule.id, assistant.id, since=began)

    assert [r.title for r in db.scalars(select(Report))] == ["Filed by the model"]


@pytest.mark.anyio
async def test_a_report_from_an_earlier_run_does_not_suppress_this_one(
    client: TestClient, db, registered, make_chat
):
    """The other half of the dedup, and the one that would fail silently: a
    daily report would be filed once and then never again, because last week's
    is still sitting there with the same `source_id`."""
    from lembas.services import reports as reports_service

    chat_id = make_chat()
    schedule = _schedule(db, chat_id=chat_id, target=TARGET_REPORT, title="Daily news")
    reports_service.create(
        db, owner=_user(db), title="Yesterday's", body="...",
        source="schedule", source_id=chat_id,
    )
    assistant = _finished(db, chat_id, "Today's news.")

    # This run began *after* yesterday's report was filed.
    await runner.deliver(schedule.id, assistant.id, since=datetime.now(tz=UTC))

    assert sorted(r.title for r in db.scalars(select(Report))) == [
        "Daily news",
        "Yesterday's",
    ]


# --- Lifecycle -------------------------------------------------------------------
def test_starting_twice_makes_one_ticker(client: TestClient, registered):
    """Two tickers in one process fires everything twice, which is the failure
    two workers would cause and the reason this is idempotent."""
    ticker.start()
    first = ticker._TICKER
    ticker.start()
    assert ticker._TICKER is first


def test_release_claims_clears_an_interrupted_run(client: TestClient, db, registered):
    """A restart abandons a firing in flight. Without this the row keeps its
    claim stamp for ever and reads as permanently running."""
    schedule = _schedule(db, claimed_at=datetime(2026, 1, 1, tzinfo=UTC))

    assert ticker.release_claims() == 1

    db.refresh(schedule)
    assert schedule.claimed_at is None
    assert "interrupted by a restart" in schedule.last_error
