"""End-to-end integration tests for the Slice 2 vertical (§8 in full).

Reuses FakeGitea + the session-cookie forging helpers from
`test_propose_vertical.py`, extends FakeGitea with the per-RFC repo
routes Slice 2 needs (PUT contents, POST orgs/{org}/repos, seeded
RFC.md), and walks the §8 vertical end-to-end against an in-process
fake Gitea:

  * Seed an active RFC with a per-RFC repo holding RFC.md.
  * GET /api/rfcs/<slug>/main and /branches/<branch> — three-column
    feed against the cache + live branch read.
  * POST promote-to-branch — cut a new branch from main.
  * Materialize an AI-style change directly in the database (the LLM
    is mocked out where possible; one separate test exercises the
    chat streaming path with a fake provider injected).
  * POST accept — runs the bot's commit and updates `changes` row.
  * POST decline — non-commit path; row persists as evidence.
  * POST manual-flush — bot commit, system message lands in branch chat.
  * POST threads — create a flag, surface it on subsequent reads.
  * POST visibility — flip read_public and contribute_mode.
  * POST chat — fake provider returns a known <change> block; the
    response materializes a `changes` row.
"""
from __future__ import annotations

import json
import pytest

# Reuse the harness already proven by Slice 1. We import via the
# top-level module name (no leading dot) because pytest discovers
# `tests/` as a flat directory of test modules without an __init__.py.
from test_propose_vertical import (  # noqa: F401 — fixtures land via import
    FakeGitea,
    app_with_fake_gitea,
    provision_user_row,
    sign_in_as,
    tmp_env,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def seed_active_rfc(fake: FakeGitea, *, slug: str, title: str, body: str) -> str:
    """Seed an active RFC end-to-end: create the meta-repo entry, the
    per-RFC repo with RFC.md on main, and the cached_rfcs row. The
    real graduation flow lands in Slice 5; until it exists, this is
    the test seam for "the RFC view's preconditions are met."
    """
    from app import db
    import yaml

    repo_full = f"wiggleverse/rfc-0001-{slug}"
    owner, repo = repo_full.split("/", 1)
    fake.seed_rfc_repo(owner, repo, rfc_md_body=body)

    # Meta-repo entry — what the cache would mirror after graduation.
    fm = {
        "slug": slug,
        "title": title,
        "state": "active",
        "id": "RFC-0001",
        "repo": repo_full,
        "proposed_by": "alice",
        "proposed_at": "2026-05-01",
        "graduated_at": "2026-05-22",
        "graduated_by": "ben",
        "owners": ["alice"],
        "arbiters": ["ben"],
        "tags": ["identity"],
    }
    entry_text = "---\n" + yaml.safe_dump(fm, sort_keys=False).rstrip() + "\n---\n"
    sha = fake._next_sha()
    fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {"content": entry_text, "sha": sha}

    # Write cached_rfcs row directly — the reconciler would also write
    # this on its next sweep, but the test seam avoids the extra hop.
    db.conn().execute(
        """
        INSERT OR REPLACE INTO cached_rfcs
            (slug, title, state, rfc_id, repo, proposed_by, proposed_at,
             graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
             body, body_sha, last_main_commit_at, last_entry_commit_at)
        VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
        """,
        (
            slug,
            title,
            "RFC-0001",
            repo_full,
            "alice",
            "2026-05-01",
            "2026-05-22",
            "ben",
            json.dumps(["alice"]),
            json.dumps(["ben"]),
            json.dumps(["identity"]),
            body,
            sha,
        ),
    )
    # Seed cached_branches for main, since the reconciler hasn't necessarily
    # run yet inside the test client's lifespan. The webhook+reconciler
    # path is what writes this in production; we shortcut it here.
    db.conn().execute(
        """
        INSERT OR IGNORE INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
        VALUES (?, 'main', ?, 'open', datetime('now'))
        """,
        (slug, sha),
    )
    return repo_full


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


SEED_BODY = """# Open Human Model

Open Human Model is a framework for representing humans.

It defines consent, trait, and agency in compatible terms.
"""


def test_rfc_main_view_renders_against_per_rfc_repo(app_with_fake_gitea):
    from fastapi.testclient import TestClient

    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=1, login="ben", role="owner")
        seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
        sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")

        r = client.get("/api/rfcs/open-human-model/main")
        assert r.status_code == 200, r.text
        d = r.json()
        assert d["slug"] == "open-human-model"
        assert "Open Human Model" in d["body"]
        # main is in the branches list (cached).
        assert any(b["name"] == "main" for b in d["branches"])


def test_promote_to_branch_creates_branch_and_navigates(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    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)
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")

        r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
        assert r.status_code == 200, r.text
        branch_name = r.json()["branch_name"]
        assert branch_name.startswith("alice-draft-")

        # The branch is reachable as its own view.
        r = client.get(f"/api/rfcs/ohm/branches/{branch_name}")
        assert r.status_code == 200, r.text
        view = r.json()
        assert view["branch_name"] == branch_name
        # The branch starts from main's body — the editor opens on it.
        assert "Open Human Model" in view["body"]
        # The whole-doc chat thread exists by default.
        assert view["main_thread_id"]

        # The bot's create_branch action is in the audit log per §6.5.
        actions = db.conn().execute(
            "SELECT action_kind, on_behalf_of FROM actions WHERE action_kind = 'create_branch'"
        ).fetchall()
        assert any((a["action_kind"], a["on_behalf_of"]) == ("create_branch", "alice") for a in actions)


def test_accept_ai_change_commits_and_updates_row(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    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)
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")

        # Cut a branch the contributor owns.
        r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
        branch = r.json()["branch_name"]

        # The whole-doc chat thread is created lazily on first branch
        # view (§8.12) — GET the branch so it materializes.
        view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
        thread_id = view["main_thread_id"]
        cur = db.conn().execute(
            """
            INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
                                  original, proposed, reason)
            VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, ?)
            """,
            (
                branch,
                thread_id,
                "Open Human Model is a framework for representing humans.",
                "Open Human Model is a framework for representing humans in software systems.",
                "tightens scope",
            ),
        )
        change_id = cur.lastrowid

        r = client.post(
            f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
            json={
                "proposed": "Open Human Model is a framework for representing humans in software systems.",
                "was_edited_before_accept": False,
            },
        )
        assert r.status_code == 200, r.text
        body = r.json()
        assert body["commit_sha"]

        # The change row is now accepted with the commit sha bound.
        row = db.conn().execute(
            "SELECT state, commit_sha, acted_by, was_edited_before_accept FROM changes WHERE id = ?",
            (change_id,),
        ).fetchone()
        assert row["state"] == "accepted"
        assert row["commit_sha"] == body["commit_sha"]
        assert row["acted_by"] == 2
        assert not row["was_edited_before_accept"]

        # The branch's RFC.md on Gitea now reflects the change.
        owner, repo = "wiggleverse", "rfc-0001-ohm"
        new_body = fake.files[(owner, repo, branch, "RFC.md")]["content"]
        assert "in software systems" in new_body


def test_accept_with_edit_before_accept_records_flag_and_ai_original(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    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)
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")

        r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
        branch = r.json()["branch_name"]
        view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
        thread_id = view["main_thread_id"]
        cur = db.conn().execute(
            """
            INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
                                  original, proposed, reason)
            VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, ?)
            """,
            (branch, thread_id,
             "It defines consent, trait, and agency in compatible terms.",
             "It defines consent, trait, harm, and agency in compatible terms.",
             "adds harm"),
        )
        change_id = cur.lastrowid

        edited = "It defines consent, trait, harm, and agency together."
        r = client.post(
            f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
            json={"proposed": edited, "was_edited_before_accept": True},
        )
        assert r.status_code == 200, r.text

        row = db.conn().execute(
            "SELECT proposed, was_edited_before_accept FROM changes WHERE id = ?",
            (change_id,),
        ).fetchone()
        assert row["was_edited_before_accept"] == 1
        assert row["proposed"] == edited
        # The commit body carries both the AI's original proposed
        # text and the contributor's revision per §8.9.
        body = fake.files[("wiggleverse", "rfc-0001-ohm", branch, "RFC.md")]["content"]
        assert "harm" in body
        # The contributor's edited text won, not the AI's.
        assert "together." in body


def test_decline_change_persists_as_evidence_no_commit(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    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)
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
        branch = r.json()["branch_name"]
        view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
        thread_id = view["main_thread_id"]
        cur = db.conn().execute(
            """
            INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
                                  original, proposed, reason)
            VALUES ('ohm', ?, ?, 'ai', 'pending', 'x', 'y', 'why')
            """,
            (branch, thread_id),
        )
        change_id = cur.lastrowid

        prior_sha = fake.branches[("wiggleverse", "rfc-0001-ohm")][branch]["sha"]
        r = client.post(f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/decline")
        assert r.status_code == 200, r.text

        # No commit, no body change.
        post_sha = fake.branches[("wiggleverse", "rfc-0001-ohm")][branch]["sha"]
        assert prior_sha == post_sha

        # The card stays as evidence.
        row = db.conn().execute(
            "SELECT state FROM changes WHERE id = ?", (change_id,)
        ).fetchone()
        assert row["state"] == "declined"


def test_manual_flush_commits_and_drops_system_message(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    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)
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
        branch = r.json()["branch_name"]

        new_body = SEED_BODY + "\n\nA new paragraph.\n"
        r = client.post(
            f"/api/rfcs/ohm/branches/{branch}/manual-flush",
            json={"new_content": new_body, "paragraph_count": 1},
        )
        assert r.status_code == 200, r.text
        assert r.json()["commit_sha"]

        # The branch RFC.md was updated.
        body = fake.files[("wiggleverse", "rfc-0001-ohm", branch, "RFC.md")]["content"]
        assert "A new paragraph" in body

        # Per §10.6: a system-author message landed in the branch chat.
        rows = db.conn().execute(
            """
            SELECT m.role, m.text FROM thread_messages m
              JOIN threads t ON t.id = m.thread_id
             WHERE t.rfc_slug = 'ohm' AND t.branch_name = ?
            """,
            (branch,),
        ).fetchall()
        assert any(r["role"] == "system" and "manual edit" in r["text"] for r in rows)


def test_create_flag_thread_surfaces_on_branch_view(app_with_fake_gitea):
    from fastapi.testclient import TestClient

    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)
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
        branch = r.json()["branch_name"]

        r = client.post(
            f"/api/rfcs/ohm/branches/{branch}/threads",
            json={
                "thread_kind": "flag",
                "anchor_kind": "range",
                "anchor_payload": {"quote": "consent"},
                "label": "needs an example",
            },
        )
        assert r.status_code == 200, r.text
        thread_id = r.json()["thread_id"]

        r = client.get(f"/api/rfcs/ohm/branches/{branch}")
        threads = r.json()["threads"]
        assert any(t["id"] == thread_id and t["thread_kind"] == "flag" for t in threads)


def test_visibility_flip_locks_out_non_grantees(app_with_fake_gitea):
    from fastapi.testclient import TestClient

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

        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
        branch = r.json()["branch_name"]

        # Flip the branch private.
        r = client.post(
            f"/api/rfcs/ohm/branches/{branch}/visibility",
            json={"read_public": False},
        )
        assert r.status_code == 200, r.text

        # Bob (a different contributor) is now blocked from reading it.
        sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
        r = client.get(f"/api/rfcs/ohm/branches/{branch}")
        assert r.status_code == 403

        # Alice (the creator) still can.
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        r = client.get(f"/api/rfcs/ohm/branches/{branch}")
        assert r.status_code == 200


def test_anonymous_can_read_main_but_not_contribute(app_with_fake_gitea):
    from fastapi.testclient import TestClient

    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
        # No sign-in.
        r = client.get("/api/rfcs/ohm/main")
        assert r.status_code == 200
        r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
        assert r.status_code == 401


def test_stale_change_refuses_silent_apply(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    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)
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
        branch = r.json()["branch_name"]
        view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
        thread_id = view["main_thread_id"]
        # Stale by construction: original text not in the document.
        cur = db.conn().execute(
            """
            INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
                                  original, proposed, reason)
            VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, ?)
            """,
            (branch, thread_id, "Text that does not appear", "Replacement.", "test"),
        )
        change_id = cur.lastrowid

        # Refused without force.
        r = client.post(
            f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
            json={"proposed": "Replacement.", "was_edited_before_accept": False},
        )
        assert r.status_code == 409
        # The row is marked stale per §8.11.
        row = db.conn().execute(
            "SELECT state, stale_since FROM changes WHERE id = ?", (change_id,)
        ).fetchone()
        assert row["state"] == "pending"
        assert row["stale_since"]

        # Force-apply succeeds and appends.
        r = client.post(
            f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
            json={"proposed": "Replacement.", "was_edited_before_accept": False, "force_apply_stale": True},
        )
        assert r.status_code == 200, r.text


# ---------------------------------------------------------------------------
# Chat streaming with a fake provider
# ---------------------------------------------------------------------------


class FakeProvider:
    name = "claude"
    display_name = "Claude"

    def __init__(self, fixed_response: str):
        self._response = fixed_response

    def send(self, system, history):
        return self._response

    def send_streaming(self, system, history):
        # Single-chunk stream — sufficient for the orchestration test.
        yield self._response


def test_chat_turn_materializes_change_from_change_block(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    app, fake = app_with_fake_gitea
    fake_response = (
        "Here is a tightening:\n\n"
        "<change>\n"
        "<original>Open Human Model is a framework for representing humans.</original>\n"
        "<proposed>Open Human Model is a framework for representing humans across software systems.</proposed>\n"
        "<reason>scopes the framework</reason>\n"
        "</change>\n\n"
        "Let me know if that fits."
    )

    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)
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
        branch = r.json()["branch_name"]

        # Inject the fake provider — the app's `providers` dict is built
        # at startup; we replace it for the test so the chat endpoint
        # resolves a deterministic response.
        app.state.providers["claude"] = FakeProvider(fake_response)
        # The router resolved `providers` at construction time; rebuild
        # the slice 2 router with the fake provider in place.
        from app import api as api_routes
        # Find and replace the existing branches router. Simpler: monkey
        # patch the providers dict referenced by the router closure.
        # The closure receives the dict by reference, so mutating it
        # propagates.
        # (Above mutation already does that — nothing more to do.)

        view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
        thread_id = view["main_thread_id"]

        r = client.post(
            f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/chat",
            json={"text": "Can you tighten the opening?", "model": "claude"},
        )
        assert r.status_code == 200, r.text
        # Drain the stream so the orchestrator finishes its work.
        body = r.content.decode()
        assert "DONE" in body

        # A change row materialized from the <change> block.
        rows = db.conn().execute(
            "SELECT kind, state, original, proposed, reason FROM changes WHERE rfc_slug = 'ohm' AND branch_name = ?",
            (branch,),
        ).fetchall()
        ai_rows = [r for r in rows if r["kind"] == "ai"]
        assert len(ai_rows) == 1
        assert ai_rows[0]["state"] == "pending"
        assert "humans across software systems" in ai_rows[0]["proposed"]
        assert "scopes the framework" in ai_rows[0]["reason"]

        # The assistant message persisted with the full text.
        msgs = db.conn().execute(
            "SELECT role, text FROM thread_messages WHERE thread_id = ? ORDER BY id",
            (thread_id,),
        ).fetchall()
        assert msgs[-1]["role"] == "assistant"
        assert "<change>" in msgs[-1]["text"]
