"""The Scheduled section: making one, changing it, and the strip on its chat.

The failures worth pinning here are the ones that look like working software:
a control wired to a method its route does not serve, a form that quietly
creates something which can never fire, and a task chat that still has a way to
send a message into it.
"""

from __future__ import annotations

from datetime import UTC, datetime

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

from lembas.db.models import KIND_TASK, TARGET_REPORT, Chat, Schedule, User
from lembas.security import permissions
from lembas.services import schedules as schedules_service
from lembas.services import settings_store
from lembas.services.schedule import clock


@pytest.fixture(autouse=True)
def scheduling_allowed(db, registered):
    """`schedule.use` is off by default, deliberately. Granted here so the tests
    are about the feature rather than about the gate — which has its own test.

    A model is configured too, and that is not scaffolding: with none, the chat
    page renders its "no models yet" branch instead of the conversation, and
    every assertion about what the composer area does or does not contain passes
    for the wrong reason. This file caught exactly that.
    """
    from lembas.db.models import Connection, Model
    from lembas.services.crypto import encrypt

    settings_store.update(db, {"enabled": True}, key=settings_store.SCHEDULES)
    settings_store.update(
        db, {"default_permissions": {"schedule.use": True, "reports.use": True}}
    )
    connection = Connection(
        name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
    )
    db.add(connection)
    db.commit()
    db.add(Model(connection_id=connection.id, model_id="test-model"))
    db.commit()
    return None


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


def _make(client: TestClient, **overrides) -> None:
    data = {
        "title": "Monday build check",
        "instruction": "Check the build and say what broke.",
        "target": "chat",
        "repeat": "calendar",
        "weekdays": "0",
        "times": "15:00",
        "count": "0",
        **overrides,
    }
    return client.post("/api/schedules", data=data, follow_redirects=False)


# --- Creating -------------------------------------------------------------------
def test_creating_a_schedule_makes_its_chat_too(client: TestClient, db, registered):
    """The one place "chats are created lazily" is bent, and on purpose: the
    first firing may be days away with nobody present to make one."""
    response = _make(client)
    assert response.status_code == 303

    schedule = db.scalars(select(Schedule)).one()
    chat = db.get(Chat, schedule.chat_id)
    assert chat is not None
    assert chat.kind == KIND_TASK
    assert response.headers["location"] == f"/chat/{chat.id}"


def test_a_new_schedule_has_a_next_run(client: TestClient, db, registered):
    """A schedule that can never fire looks exactly like a working one on the
    list page. This is the invariant that stops one being written at all."""
    _make(client)
    schedule = db.scalars(select(Schedule)).one()

    assert schedule.next_fire_at is not None
    assert clock.as_utc(schedule.next_fire_at) > datetime.now(tz=UTC)
    assert schedule.enabled is True


def test_a_rule_that_means_nothing_is_refused_with_a_reason(
    client: TestClient, db, registered
):
    """Not a 400 nobody can act on: back to the form, with the reason. A silent
    refusal here would be a "Schedule it" button that appears to do nothing."""
    response = _make(client, repeat="once", start_date="", start_time="")

    assert response.status_code == 303
    assert "/scheduled/new?error=" in response.headers["location"]
    assert db.scalars(select(Schedule)).all() == []


def test_a_one_shot_in_the_past_is_refused(client: TestClient, db, registered):
    response = _make(client, repeat="once", start_date="2020-01-01", start_time="09:00")

    assert "error=" in response.headers["location"]
    assert db.scalars(select(Schedule)).all() == []


def test_the_form_and_the_engine_agree_about_what_was_stored(
    client: TestClient, db, registered
):
    """The edit screen is derived from the *normalised* rule, so a form showing
    something other than what runs is impossible rather than merely unlikely."""
    _make(client, repeat="every", every_amount="6", every_unit="hours")
    schedule = db.scalars(select(Schedule)).one()

    page = client.get(f"/scheduled/{schedule.id}/edit").text
    assert 'value="every"\n           checked' in page or 'value="every" checked' in page
    assert 'value="6"' in page


def test_a_per_user_ceiling_is_enforced(client: TestClient, db, registered):
    settings_store.update(db, {"max_per_user": 1}, key=settings_store.SCHEDULES)
    _make(client)

    response = _make(client, title="A second one")

    assert "error=" in response.headers["location"]
    assert len(db.scalars(select(Schedule)).all()) == 1


# --- The gate --------------------------------------------------------------------
def test_scheduling_is_off_unless_granted(client: TestClient, db, registered):
    """`schedule.use` defaults to False: this spends model time with nobody at
    the keyboard, which is a capability chosen on purpose."""
    assert permissions.DEFAULT_PERMISSIONS["schedule.use"] is False


def _as_stranger(client: TestClient, db, *, role: str = "user") -> User:
    """Sign in as somebody who is not the administrator.

    Necessary for any test about a permission: `permissions.resolve` gives an
    admin everything, so asking Frodo whether a gate works answers a different
    question and answers it yes.
    """
    from lembas.security.passwords import hash_password

    stranger = User(
        email="sam@shire.test",
        name="Sam",
        password_hash=hash_password("gardening-is-hard"),
        role=role,
    )
    db.add(stranger)
    db.commit()
    client.post("/auth/logout")
    client.post(
        "/auth/login", data={"email": "sam@shire.test", "password": "gardening-is-hard"}
    )
    return stranger


def test_the_sidebar_entry_follows_the_permission(client: TestClient, db, registered):
    _as_stranger(client, db)
    assert 'href="/scheduled"' in client.get("/chat").text

    settings_store.update(db, {"default_permissions": {"schedule.use": False}})
    assert 'href="/scheduled"' not in client.get("/chat").text


def test_a_non_admin_without_the_permission_cannot_reach_it(
    client: TestClient, db, registered
):
    settings_store.update(db, {"default_permissions": {"schedule.use": False}})
    _as_stranger(client, db)

    assert client.get("/scheduled", follow_redirects=False).status_code in (302, 303, 403)
    assert client.post("/api/schedules", data={}, follow_redirects=False).status_code in (
        302,
        303,
        403,
    )


# --- The task chat ----------------------------------------------------------------
def test_a_task_chat_has_no_composer(client: TestClient, db, registered):
    """Suppressed by absence, not by hiding: `chat/_composer.html` is the only
    thing that posts a message, so its absence is the guarantee. A hidden one
    would still be a form anybody could post to."""
    _make(client)
    schedule = db.scalars(select(Schedule)).one()

    body = client.get(f"/chat/{schedule.chat_id}").text
    assert "composer__form" not in body
    assert 'name="content"' not in body
    # And the controls that do apply are there instead.
    assert f"/api/schedules/{schedule.id}/run" in body
    assert f"/api/schedules/{schedule.id}/toggle" in body


def test_the_strip_survives_its_schedule_being_removed(
    client: TestClient, db, registered
):
    """Removing a schedule keeps its chat by default. The chat becomes an
    ordinary one, so it is reachable — a KIND_TASK chat with no schedule behind
    it would be in no list at all."""
    _make(client)
    schedule = db.scalars(select(Schedule)).one()
    chat_id = schedule.chat_id

    client.post(f"/api/schedules/{schedule.id}/delete", data={"keep_chat": "1"})

    chat = db.get(Chat, chat_id)
    db.refresh(chat)
    assert chat is not None
    assert chat.kind == "chat"
    assert client.get(f"/chat/{chat_id}").status_code == 200


def test_removing_a_schedule_can_take_its_chat(client: TestClient, db, registered):
    _make(client)
    schedule = db.scalars(select(Schedule)).one()
    chat_id = schedule.chat_id

    client.post(f"/api/schedules/{schedule.id}/delete", data={"keep_chat": "0"})

    db.expunge_all()
    assert db.get(Chat, chat_id) is None


# --- Controls that write -----------------------------------------------------------
def test_pausing_and_resuming_move_the_row(client: TestClient, db, registered):
    """Asserted on the row rather than on the response: a control wired to a
    method its route does not serve returns 405 and looks exactly like working
    software, which cost the agent-mode select an entire release."""
    _make(client)
    schedule = db.scalars(select(Schedule)).one()

    client.post(f"/api/schedules/{schedule.id}/toggle", data={"enabled": "0"})
    db.refresh(schedule)
    assert schedule.enabled is False

    client.post(f"/api/schedules/{schedule.id}/toggle", data={"enabled": "1"})
    db.refresh(schedule)
    assert schedule.enabled is True


def test_resuming_recomputes_from_now(client: TestClient, db, registered):
    """A schedule paused for a month must not come back owing a month of runs.
    Without this it fires the instant it is switched on."""
    _make(client, repeat="every", every_amount="1", every_unit="hours")
    schedule = db.scalars(select(Schedule)).one()

    schedule.enabled = False
    schedule.next_fire_at = datetime(2020, 1, 1, tzinfo=UTC)
    db.commit()

    schedules_service.set_enabled(db, schedule, owner=_user(db), enabled=True)

    assert clock.as_utc(schedule.next_fire_at) > datetime.now(tz=UTC)


def test_editing_the_rule_restarts_the_count(client: TestClient, db, registered):
    """An edited schedule is a new intention. Carrying the old `fired_count`
    into a new `count` would spend most of it before the first run."""
    _make(client)
    schedule = db.scalars(select(Schedule)).one()
    schedule.fired_count = 7
    db.commit()

    client.post(
        f"/api/schedules/{schedule.id}",
        data={
            "title": "Changed",
            "instruction": "Something else.",
            "target": "report",
            "repeat": "every",
            "every_amount": "2",
            "every_unit": "hours",
            "count": "3",
        },
    )

    db.refresh(schedule)
    assert schedule.fired_count == 0
    assert schedule.title == "Changed"
    assert schedule.target == TARGET_REPORT


def test_the_routes_refuse_the_wrong_verb(client: TestClient, db, registered):
    """The other half of the agent-mode lesson: assert the wrong method is
    *refused*, because only that half would have failed throughout."""
    _make(client)
    schedule = db.scalars(select(Schedule)).one()

    assert client.get(f"/api/schedules/{schedule.id}/toggle").status_code == 405
    assert client.get(f"/api/schedules/{schedule.id}/run").status_code == 405
    assert client.patch(f"/api/schedules/{schedule.id}/delete").status_code == 405


def test_one_persons_schedule_is_not_anothers(client: TestClient, db, registered):
    from lembas.security.passwords import hash_password

    _make(client)
    schedule = db.scalars(select(Schedule)).one()

    stranger = User(
        email="sam@shire.test",
        name="Sam",
        password_hash=hash_password("gardening-is-hard"),
        role="admin",
    )
    db.add(stranger)
    db.commit()

    assert schedules_service.get(db, schedule.id, stranger) is None


# --- The admin page ----------------------------------------------------------------
def test_the_admin_page_saves_and_clamps(client: TestClient, db, registered):
    """Clamped on read as well as here, for the reason `agents` and `images`
    give: a value stored by an earlier release, or edited into the database by
    hand, has to be survivable too. What this asserts is that neither half has
    been quietly dropped."""
    response = client.post(
        "/admin/schedules",
        data={
            "enabled": "true",
            "tick_seconds": "1",
            "max_per_user": "9999",
            "max_concurrent": "0",
            "min_interval_seconds": "1",
            "max_queued": "500",
        },
        follow_redirects=False,
    )
    assert response.status_code == 303

    values = settings_store.schedules(db)
    assert values["enabled"] is True
    assert values["tick_seconds"] == 5        # floor: a busy loop otherwise
    assert values["max_per_user"] == 200      # ceiling
    # 0 falls back to the default rather than clamping to 1, because the
    # accessor reads `int(stored or default)` -- the same shape `agents` and
    # `images` use. There is no reading of "no runs at once" that anybody wants:
    # it would be a ticker that claims work and never does it.
    assert values["max_concurrent"] == 3
    assert values["min_interval_seconds"] == 60
    assert values["max_queued"] == 50


def test_the_switch_actually_stops_firing(client: TestClient, db, registered):
    """A switch that only greys something out is the failure. The sweep reads
    it, so a schedule created while it was on stops when it is turned off."""
    import anyio

    from lembas.services.schedule import ticker

    _make(client)
    client.post("/admin/schedules", data={"enabled": ""}, follow_redirects=False)

    assert anyio.run(ticker.sweep) == 0


def test_the_admin_page_is_admin_only(client: TestClient, db, registered):
    _as_stranger(client, db)
    assert client.get("/admin/schedules", follow_redirects=False).status_code in (
        302, 303, 403, 404,
    )
