"""M4 feedback loop + M5 review pack & the four new lenses (spec §5.4-§5.6).

The feedback delivery is the **one sanctioned write** beyond display, and only into a
target's ``feedback/`` inbox — never ``generated/``, never git. The load-bearing test
snapshots the project's ``generated/`` tree across a delivery to prove that boundary
holds. The new lenses' time-sensitive logic (a stale ``in_progress``, the next-best
score order) is pinned with hand-built providers so the assertions never drift with the
calendar; the route tests confirm they render honestly through the real app.
"""

from __future__ import annotations

import json
from typing import TYPE_CHECKING, Any

from mgf.fedres import DiskProvider, validate_payload

from mgf.fed_web.lenses import changed_lens, coupling_lens, nextbest_lens, stalled_lens

if TYPE_CHECKING:
    from pathlib import Path

    from fastapi.testclient import TestClient


# --------------------------------------------------------------------------- #
# helpers                                                                      #
# --------------------------------------------------------------------------- #
def _status_provider(root: Path, tasks: list[dict[str, Any]]) -> DiskProvider:
    """A bare provider whose ``generated/status.json`` carries exactly ``tasks``."""
    gen = root / "fed-resources" / "generated"
    gen.mkdir(parents=True)
    payload = {
        "schema_version": "0.1",
        "kind": "status",
        "project": "x",
        "summary": {"total": len(tasks), "by_state": {}},
        "tasks": tasks,
    }
    assert not validate_payload(payload), "test status payload must be schema-valid"
    (gen / "status.json").write_text(json.dumps(payload), encoding="utf-8")
    return DiskProvider(root)


def _generated_snapshot(root: Path, slug: str) -> dict[str, bytes]:
    """Every byte under a project's ``generated/`` (the non-mutation proof surface)."""
    gen = root / slug / "fed-resources" / "generated"
    return {str(p.relative_to(gen)): p.read_bytes() for p in sorted(gen.rglob("*")) if p.is_file()}


# --------------------------------------------------------------------------- #
# M4 — the feedback loop                                                       #
# --------------------------------------------------------------------------- #
def test_feedback_page_offers_compose_form_into_the_next_round(
    synthetic_client: TestClient,
) -> None:
    r = synthetic_client.get("/p/alpha/feedback")
    assert r.status_code == 200
    assert "Compose feedback" in r.text
    # No rounds yet → the next delivery opens round v1, and the thread is honestly empty.
    assert "round v1" in r.text
    assert "No feedback rounds yet" in r.text


def test_feedback_delivery_writes_only_into_the_inbox_and_threads(
    synthetic: Path, synthetic_client: TestClient
) -> None:
    before = _generated_snapshot(synthetic, "alpha")

    r = synthetic_client.post(
        "/p/alpha/feedback",
        data={
            "kind": "direction",
            "priority": "high",
            "title": "Freeze the schema",
            "body": "Lock 0.1 before B.",
        },
    )
    assert r.status_code == 200
    assert "delivered to round v1" in r.text

    # The one sanctioned write landed in feedback/, schema-valid, and canonical...
    delivered = (
        synthetic / "alpha" / "fed-resources" / "feedback" / "rounds" / "v1" / "feedback.json"
    )
    assert delivered.is_file()
    payload = json.loads(delivered.read_text())
    assert not validate_payload(payload)
    assert payload["round"] == 1 and payload["items"][0]["title"] == "Freeze the schema"

    # ...and NOTHING under generated/ moved (the read-mostly boundary held).
    assert _generated_snapshot(synthetic, "alpha") == before

    # The round now threads, open (no response yet) — derived, not asserted.
    thread = synthetic_client.get("/p/alpha/feedback")
    assert "Round v1" in thread.text
    assert "open" in thread.text


def test_feedback_delivery_is_fail_closed_on_an_empty_item(
    synthetic: Path, synthetic_client: TestClient
) -> None:
    r = synthetic_client.post("/p/alpha/feedback", data={"title": "  ", "body": ""})
    assert r.status_code == 200
    assert "not delivered" in r.text
    # Fail-closed: a blank item is never written into the inbox.
    assert not (synthetic / "alpha" / "fed-resources" / "feedback" / "rounds").exists()


def test_round_reads_answered_when_a_matching_response_is_present(
    synthetic: Path, synthetic_client: TestClient
) -> None:
    rdir = synthetic / "alpha" / "fed-resources" / "feedback" / "rounds" / "v1"
    rdir.mkdir(parents=True)
    feedback = {
        "schema_version": "0.1",
        "kind": "feedback",
        "round": 1,
        "project": "alpha",
        "authored_by": "founder",
        "authored_at": "2026-06-29T00:00:00Z",
        "items": [
            {"id": "FB-1", "kind": "question", "title": "Ship date?", "body": "When does B land?"}
        ],
    }
    response = {
        "schema_version": "0.1",
        "kind": "response",
        "round": 1,
        "project": "alpha",
        "responded_by": "implementer",
        "responded_at": "2026-06-29T01:00:00Z",
        "answers": [{"feedback_id": "FB-1", "status": "accepted", "body": "B lands next week."}],
        "agent_raised": [{"id": "AR-1", "kind": "concern", "body": "The schema freeze blocks B."}],
    }
    assert not validate_payload(feedback) and not validate_payload(response)
    (rdir / "feedback.json").write_text(json.dumps(feedback), encoding="utf-8")
    (rdir / "response.json").write_text(json.dumps(response), encoding="utf-8")

    r = synthetic_client.get("/p/alpha/feedback")
    assert r.status_code == 200
    assert "answered" in r.text  # derived: every item has a matching answer
    assert "B lands next week." in r.text
    assert "the project agent raised" in r.text  # the agent_raised channel threads too


def test_feedback_on_an_unknown_project_is_honest_absence_not_500(
    synthetic_client: TestClient,
) -> None:
    get = synthetic_client.get("/p/ghost/feedback")
    assert get.status_code == 200
    assert "not wired" in get.text  # honest UNKNOWN header, never a crash
    # A delivery POST against a bad slug is just as honest (no 500, no write).
    post = synthetic_client.post("/p/ghost/feedback", data={"title": "x", "body": "y"})
    assert post.status_code == 200


# --------------------------------------------------------------------------- #
# M5 — the review pack                                                         #
# --------------------------------------------------------------------------- #
def test_review_pack_gathers_every_lens_plus_feedback(synthetic_client: TestClient) -> None:
    r = synthetic_client.get("/p/alpha/review-pack")
    assert r.status_code == 200
    assert "Review pack" in r.text
    # Every lens label appears as a pack section heading...
    for label in (
        "Roadmap",
        "In progress",
        "Stalled",
        "Next best",
        "Decisions",
        "Changed",
        "Coupling",
        "Architecture",
    ):
        assert label in r.text, f"review pack is missing the {label} section"
    # ...and the feedback loop rides along (the meeting-ready snapshot).
    assert "Feedback" in r.text


def test_review_pack_on_an_unknown_project_is_honest_absence_not_500(
    synthetic_client: TestClient,
) -> None:
    r = synthetic_client.get("/p/ghost/review-pack")
    assert r.status_code == 200
    assert "not wired" in r.text


# --------------------------------------------------------------------------- #
# M5 — the four new lenses render through the app                              #
# --------------------------------------------------------------------------- #
def test_new_lens_routes_all_render(synthetic_client: TestClient) -> None:
    for lens in ("stalled", "nextbest", "changed", "coupling"):
        r = synthetic_client.get(f"/p/alpha/{lens}")
        assert r.status_code == 200, f"{lens} lens did not render"


def test_nextbest_route_surfaces_the_ready_candidate(synthetic_client: TestClient) -> None:
    r = synthetic_client.get("/p/alpha/nextbest")
    assert r.status_code == 200
    assert "A-2" in r.text and "Approve the schema freeze" in r.text


# --------------------------------------------------------------------------- #
# M5 — time-stable lens logic (hand-built providers)                           #
# --------------------------------------------------------------------------- #
def test_stalled_lists_blocked_and_ancient_in_progress_and_flags_the_unexplained(
    tmp_path: Path,
) -> None:
    provider = _status_provider(
        tmp_path,
        [
            {
                "id": "T-blk",
                "title": "Vendor call",
                "state": "blocked",
                "reason": "waiting on vendor",
            },
            {
                "id": "T-old",
                "title": "Half-done refactor",
                "state": "in_progress",
                "started_at": "2020-01-01T00:00:00Z",
            },
            {"id": "T-done", "title": "Shipped", "state": "done"},
        ],
    )
    view = stalled_lens(provider)
    assert view["ok"]
    assert {i["why"] for i in view["items"]} == {"blocked", "stale_in_progress"}
    # the blocked task explains itself; the stale in_progress doesn't → it's flagged
    assert view["flagged"] == 1


def test_nextbest_scores_ready_over_blocked_and_orders_by_value(tmp_path: Path) -> None:
    provider = _status_provider(
        tmp_path,
        [
            {
                "id": "T-hi",
                "title": "High impact, small",
                "state": "next",
                "impact": "high",
                "effort": "S",
            },
            {
                "id": "T-lo",
                "title": "Low impact, large",
                "state": "next",
                "impact": "low",
                "effort": "L",
            },
            {
                "id": "T-blk",
                "title": "Blocked on missing dep",
                "state": "next",
                "impact": "high",
                "effort": "S",
                "depends_on": ["T-absent"],
            },
        ],
    )
    view = nextbest_lens(provider)
    assert view["ok"]
    ready_ids = [r["task"]["id"] for r in view["ready"]]
    assert ready_ids == ["T-hi", "T-lo"], "higher-value ready work ranks first"
    # the deps-blocked candidate is never dropped — it's listed apart, with what it waits on
    blocked_ids = [r["task"]["id"] for r in view["blocked"]]
    assert blocked_ids == ["T-blk"]
    assert view["blocked"][0]["blocked_on"] == ["T-absent"]


def test_coupling_and_changed_are_honest_on_a_bare_provider(tmp_path: Path) -> None:
    provider = DiskProvider(tmp_path)  # no federation.json, no committed history
    assert coupling_lens(provider)["ok"] is False  # honest "nothing reported"
    changed = changed_lens(provider)
    assert changed["ok"] is True and changed["single"] is True  # honest "not enough history"
