"""End-to-end integration tests for the Slice 8 §12 + §10.7 hygiene
vertical.

The slice closes §11.5's branch-lifecycle loop. The scheduler shape
mirrors `DigestScheduler` — the `run_tick(now=...)` seam lets the tests
compress the 30/90-day windows to seconds without monkey-patching the
clock.

The tests prove:

  * Per §11.5 idle hygiene: a branch with no PR and `last_commit_at`
    past the 30-day mark flips to `state='closed'` and an audit row
    with `action_kind='close_idle_branch'` lands. `on_behalf_of` is
    the bot login and `actor_user_id` is NULL per §15.9.
  * Per §10.7: a per-RFC PR whose merge fell past the 90-day mark
    triggers a branch deletion via the bot — `delete_post_merge_branch`
    audit row, `cached_branches.state` flips to 'deleted', and the
    branch is gone from Gitea (the FakeGitea models the DELETE).
  * Per §12: a closed branch past the 90-day mark gets deleted
    (`delete_stale_branch`).
  * The per-user message-cursor preservation contract per §11.5:
    `branch_chat_seen` rows survive the hygiene sweep even when the
    branch row flips to 'deleted'.
  * The graduation rollback's branch cleanup deletes the
    `graduate-<slug>-<6hex>` branch (per the §19.2 candidate Slice 8
    settles).
  * `notify._AUTO_WATCH_ACTIONS` doesn't include the hygiene kinds,
    so no notifications fire.
  * Pinned branches are exempt from the close and delete passes.
"""
from __future__ import annotations

import asyncio
import json as _json
from datetime import datetime, timedelta, timezone

import pytest

from test_propose_vertical import (  # noqa: F401
    FakeGitea,
    app_with_fake_gitea,
    provision_user_row,
    sign_in_as,
    tmp_env,
)
from test_super_draft_vertical import seed_super_draft  # noqa: F401
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc  # noqa: F401


def _run_hygiene(*, now: datetime, app):
    """Drive one hygiene tick synchronously. The scheduler's tests-only
    `run_tick(now=...)` seam mirrors DigestScheduler's `run_tick()`
    pattern from Slice 6."""
    from app import hygiene
    config = app.state.config
    bot = app.state.bot
    return asyncio.get_event_loop().run_until_complete(
        hygiene.run_tick(config=config, bot=bot, now=now)
    )


def _aiorun(coro):
    """Synchronous awaiter for use inside TestClient blocks (already
    inside an event loop). The hygiene module is async; pytest's
    asyncio support would also work, but matching the Slice 6 digest
    tests' shape keeps the file readable."""
    loop = asyncio.new_event_loop()
    try:
        return loop.run_until_complete(coro)
    finally:
        loop.close()


# ---------------------------------------------------------------------------
# §12 30/90 timers
# ---------------------------------------------------------------------------


def test_idle_branch_flips_to_closed_at_30_day_mark(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db, hygiene

    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)

        # Insert an idle open branch directly; the bot's create_branch
        # path normally writes this row through the cache reconciler.
        # We backdate `last_commit_at` to 40 days ago so the 30-day
        # window is breached.
        forty_days = (datetime.now(timezone.utc) - timedelta(days=40)).strftime("%Y-%m-%d %H:%M:%S")
        db.conn().execute(
            """
            INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
            VALUES ('ohm', 'idle-branch', 'sha-idle', 'open', ?)
            """,
            (forty_days,),
        )
        # No cached_prs row → idle path applies.

        counters = _aiorun(hygiene.run_tick(config=app.state.config, bot=app.state.bot))
        assert counters["closed_idle"] == 1, counters

        row = db.conn().execute(
            "SELECT state, closed_at FROM cached_branches WHERE branch_name = 'idle-branch'"
        ).fetchone()
        assert row["state"] == "closed"
        assert row["closed_at"]  # populated

        audit = db.conn().execute(
            "SELECT actor_user_id, on_behalf_of, action_kind FROM actions WHERE action_kind = 'close_idle_branch'"
        ).fetchone()
        assert audit is not None
        assert audit["actor_user_id"] is None  # §15.9 system-generated
        assert audit["on_behalf_of"] == "rfc-bot"


def test_closed_branch_deleted_at_90_day_mark(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db, hygiene

    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
        owner, repo = "wiggleverse", "rfc-0001-ohm"

        # Create the branch in FakeGitea so the delete has something to
        # remove; the cached_branches row points at the same name.
        fake.branches[(owner, repo)]["stale-branch"] = {
            "sha": "sha-stale", "ts": "2026-01-01T00:00:00Z",
        }
        long_ago = (datetime.now(timezone.utc) - timedelta(days=120)).strftime("%Y-%m-%d %H:%M:%S")
        db.conn().execute(
            """
            INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at, closed_at)
            VALUES ('ohm', 'stale-branch', 'sha-stale', 'closed', ?, ?)
            """,
            (long_ago, long_ago),
        )

        counters = _aiorun(hygiene.run_tick(config=app.state.config, bot=app.state.bot))
        assert counters["deleted_stale"] == 1, counters

        # Cached row flipped + Gitea branch gone.
        row = db.conn().execute(
            "SELECT state FROM cached_branches WHERE branch_name = 'stale-branch'"
        ).fetchone()
        assert row["state"] == "deleted"
        assert "stale-branch" not in fake.branches[(owner, repo)]

        audit = db.conn().execute(
            """
            SELECT actor_user_id, on_behalf_of, branch_name
              FROM actions WHERE action_kind = 'delete_stale_branch'
            """
        ).fetchone()
        assert audit["actor_user_id"] is None
        assert audit["on_behalf_of"] == "rfc-bot"
        assert audit["branch_name"] == "stale-branch"


def test_pinned_branch_skipped_by_both_passes(app_with_fake_gitea):
    """§12 / §11.5: owners and arbiters can pin a branch to disable
    the auto-close timer. The pin must hold against both the 30-day
    close and the 90-day delete."""
    from fastapi.testclient import TestClient
    from app import db, hygiene

    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
        owner, repo = "wiggleverse", "rfc-0001-ohm"
        fake.branches[(owner, repo)]["pinned-branch"] = {
            "sha": "sha-pin", "ts": "2026-01-01T00:00:00Z",
        }
        ancient = (datetime.now(timezone.utc) - timedelta(days=200)).strftime("%Y-%m-%d %H:%M:%S")
        # Pinned + ancient + open → should NOT close.
        db.conn().execute(
            """
            INSERT INTO cached_branches
                (rfc_slug, branch_name, head_sha, state, last_commit_at, pinned)
            VALUES ('ohm', 'pinned-branch', 'sha-pin', 'open', ?, 1)
            """,
            (ancient,),
        )

        counters = _aiorun(hygiene.run_tick(config=app.state.config, bot=app.state.bot))
        assert counters["closed_idle"] == 0
        assert counters["deleted_stale"] == 0
        assert "pinned-branch" in fake.branches[(owner, repo)]


def test_post_merge_branch_deleted_at_90_day_mark(app_with_fake_gitea):
    """§10.7: a per-RFC PR whose merge fell past the 90-day mark gets
    its branch deleted by the bot, with audit_kind='delete_post_merge_branch'.
    Rides on the §12 hygiene sweep per the §19.1 brief."""
    from fastapi.testclient import TestClient
    from app import db, hygiene

    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
        owner, repo = "wiggleverse", "rfc-0001-ohm"

        # Mock a merged PR with a stale merged_at.
        fake.branches[(owner, repo)]["alice-draft-deadbeef"] = {
            "sha": "sha-merged", "ts": "2026-01-01T00:00:00Z",
        }
        stale_merge = (datetime.now(timezone.utc) - timedelta(days=120)).strftime("%Y-%m-%d %H:%M:%S")
        db.conn().execute(
            """
            INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
            VALUES ('ohm', 'alice-draft-deadbeef', 'sha-merged', 'open', ?)
            """,
            (stale_merge,),
        )
        db.conn().execute(
            """
            INSERT INTO cached_prs
                (rfc_slug, pr_kind, repo, pr_number, title, state,
                 opened_by, opened_at, merged_at, head_branch, base_branch, head_sha)
            VALUES
                ('ohm', 'rfc_branch', ?, 42, 'Edit OHM', 'merged',
                 'alice', ?, ?, 'alice-draft-deadbeef', 'main', 'sha-merged')
            """,
            (f"{owner}/{repo}", stale_merge, stale_merge),
        )

        counters = _aiorun(hygiene.run_tick(config=app.state.config, bot=app.state.bot))
        # The branch was open + had a merged PR 120d ago → both the 30d
        # post-merge close and the 90d delete fire in the same sweep.
        # The 90d delete is the load-bearing assertion.
        assert counters["deleted_post_merge"] == 1, counters

        assert "alice-draft-deadbeef" not in fake.branches[(owner, repo)]

        audit = db.conn().execute(
            """
            SELECT actor_user_id, on_behalf_of FROM actions
             WHERE action_kind = 'delete_post_merge_branch' AND branch_name = 'alice-draft-deadbeef'
            """
        ).fetchone()
        assert audit is not None
        assert audit["actor_user_id"] is None
        assert audit["on_behalf_of"] == "rfc-bot"


def test_branch_chat_seen_survives_branch_deletion(app_with_fake_gitea):
    """§11.5 per-user message-cursor preservation contract: deleting
    the branch in Gitea and flipping cached_branches.state='deleted'
    leaves the per-user branch_chat_seen rows untouched. Chat history
    survives the branch's deletion because the chat tables are
    app-canonical, not cached."""
    from fastapi.testclient import TestClient
    from app import db, hygiene

    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
        owner, repo = "wiggleverse", "rfc-0001-ohm"
        fake.branches[(owner, repo)]["doomed"] = {
            "sha": "sha-doomed", "ts": "2026-01-01T00:00:00Z",
        }

        long_ago = (datetime.now(timezone.utc) - timedelta(days=120)).strftime("%Y-%m-%d %H:%M:%S")
        db.conn().execute(
            """
            INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at, closed_at)
            VALUES ('ohm', 'doomed', 'sha-doomed', 'closed', ?, ?)
            """,
            (long_ago, long_ago),
        )
        # Seed a chat thread + message on the doomed branch, then the
        # per-user seen cursor pointing at the message. The FK on
        # branch_chat_seen.last_seen_message_id requires a real
        # thread_messages row.
        cur = db.conn().execute(
            """
            INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, created_by)
            VALUES ('ohm', 'doomed', 'whole-doc', 'chat', 2)
            """
        )
        thread_id = cur.lastrowid
        cur = db.conn().execute(
            """
            INSERT INTO thread_messages (thread_id, role, author_user_id, text)
            VALUES (?, 'user', 2, 'witness this')
            """,
            (thread_id,),
        )
        message_id = cur.lastrowid
        db.conn().execute(
            """
            INSERT INTO branch_chat_seen (user_id, rfc_slug, branch_name, last_seen_message_id, seen_at)
            VALUES (2, 'ohm', 'doomed', ?, datetime('now'))
            """,
            (message_id,),
        )

        _aiorun(hygiene.run_tick(config=app.state.config, bot=app.state.bot))

        # The branch row flipped to deleted.
        b = db.conn().execute(
            "SELECT state FROM cached_branches WHERE branch_name = 'doomed'"
        ).fetchone()
        assert b["state"] == "deleted"
        # But the per-user cursor is preserved.
        seen = db.conn().execute(
            "SELECT last_seen_message_id FROM branch_chat_seen WHERE user_id = 2 AND branch_name = 'doomed'"
        ).fetchone()
        assert seen is not None
        assert seen["last_seen_message_id"] == message_id
        # And the chat message itself survives — app-canonical, not cached.
        msg = db.conn().execute(
            "SELECT text FROM thread_messages WHERE id = ?", (message_id,)
        ).fetchone()
        assert msg["text"] == "witness this"


def test_hygiene_action_kinds_fire_no_notifications(app_with_fake_gitea):
    """§15.1 routing: the hygiene action kinds (`close_idle_branch`,
    `delete_stale_branch`, `delete_post_merge_branch`) are intentionally
    absent from notify._AUTO_WATCH_ACTIONS and notify._ROUTING. The
    spec doesn't commit a notification for them, and the right call is
    no notification — stale-branch deletes targeting watchers who
    haven't engaged in 90 days would be churn-grade noise per §15.4."""
    from app import notify

    for kind in ("close_idle_branch", "delete_stale_branch", "delete_post_merge_branch"):
        assert kind not in notify._AUTO_WATCH_ACTIONS, kind
        assert kind not in notify._ROUTING, kind


# ---------------------------------------------------------------------------
# Graduation rollback's branch cleanup
# ---------------------------------------------------------------------------


def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
    """Meta-only (§13.3): when the flip's merge fails after the PR is
    open, the orchestrator closes the PR and deletes its
    `graduate-<slug>-<6hex>` branch so failed attempts don't accumulate
    on the meta repo across retries."""
    from fastapi.testclient import TestClient
    from app import db
    from app.bot import Bot
    from app.gitea import GiteaError

    from test_graduation_vertical import seed_owned_super_draft, PITCH  # noqa: F401

    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=1, login="ben", role="owner")
        seed_owned_super_draft(
            fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"],
        )
        sign_in_as(client, user_id=1, gitea_login="ben",
                   display_name="Ben", role="owner")

        # Force a merge_pr failure so the flip PR (open_pr) has already
        # landed and the cleanup exercises the branch deletion.
        orig_merge = Bot.merge_graduation_pr
        async def boom(self, *args, **kwargs):
            raise GiteaError(502, "simulated merge failure for cleanup test")
        Bot.merge_graduation_pr = boom
        try:
            r = client.post(
                "/api/rfcs/ohm/graduate?_sync=1",
                json={"rfc_id": "RFC-0099", "owners": ["ben"]},
            )
        finally:
            Bot.merge_graduation_pr = orig_merge
        assert r.status_code == 200, r.text
        assert r.json()["succeeded"] is False

        # The dash-suffixed graduation branch was deleted on cleanup.
        meta_branches = fake.branches[("wiggleverse", "meta")]
        graduation_branches = [n for n in meta_branches if n.startswith("graduate-ohm-")]
        assert graduation_branches == [], (
            f"expected no graduate-ohm-* branches after rollback, got {graduation_branches}"
        )

        # Audit log carries delete_post_merge_branch (the action kind
        # the rollback path reuses for the cleanup).
        kinds = [
            r["action_kind"]
            for r in db.conn().execute(
                "SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
            )
        ]
        assert "graduate_pr_close" in kinds
        assert "delete_post_merge_branch" in kinds
