"""Easel domain API: projects/screens/options, selection, send, pins+threads,
walkthrough approvals, notifications, and the live rollup — driven through the
HTTP surface exactly as the SPA drives it.

Cast: rian (owner) · mara (admin = manager side) · cleo (member granted on the
project = client side) · nate (member granted elsewhere = must see nothing).
"""

from tests.conftest import as_user


def seed_people(kit):
    kit.member("mara", "admin", all_instances=True)
    kit.member("cleo", "member")
    kit.member("nate", "member")


def make_project(client, kit, name="League Concepts"):
    seed_people(kit)
    as_user(client, "mara")
    r = client.post("/api/projects", json={"name": name})
    assert r.status_code == 200, r.text
    iid = r.json()["id"]
    kit.grant("cleo", iid, "member")
    kit.instance("elsewhere", "Elsewhere")
    kit.grant("nate", "elsewhere", "member")
    return iid


MOCKUP = b"<!doctype html><title>c</title><body><h1>Concept</h1></body>"


def add_screen_option(client, iid, screen_title="Homepage", with_mockup=True,
                      published=True):
    """A screen with one option, PUBLISHED and carrying a mockup — i.e. a screen
    the client can actually see and act on, which is what almost every test means
    by "a screen".

    Both defaults encode a real contract. An option with no files is work in
    progress: invisible to the client, not counted as ready. A screen starts as a
    DRAFT: the team's alone until published. Pass `published=False` or
    `with_mockup=False` to build the half-finished states deliberately.
    """
    r = client.post(f"/api/projects/{iid}/screens", json={"title": screen_title})
    assert r.status_code == 200, r.text
    sid = r.json()["id"]
    if published:
        assert client.patch(f"/api/screens/{sid}",
                            json={"published": True}).status_code == 200
    r = client.post(f"/api/screens/{sid}/options",
                    json={"title": "Option 1", "concept_tag": "Direct Answer"})
    assert r.status_code == 200, r.text
    oid = r.json()["id"]
    if with_mockup:
        r = client.post(f"/api/options/{oid}/files",
                        files=[("files", ("index.html", MOCKUP, "text/html"))])
        assert r.status_code == 200, r.text
    return sid, oid


# ----------------------------------------------------------------- projects

def test_project_lifecycle_and_visibility(client, kit):
    iid = make_project(client, kit)
    sid, oid = add_screen_option(client, iid)

    # The manager sees it with manage capabilities.
    r = client.get(f"/api/projects/{iid}")
    assert r.status_code == 200
    detail = r.json()
    assert detail["capabilities"]["can_manage"] is True
    assert detail["screens"][0]["options"][0]["id"] == oid
    assert detail["state"] == "in_progress"

    # The granted client sees it, without manage; approval is theirs.
    as_user(client, "cleo")
    r = client.get(f"/api/projects/{iid}")
    assert r.status_code == 200
    caps = r.json()["capabilities"]
    assert caps["can_manage"] is False
    assert caps["can_comment"] is True
    assert caps["can_approve"] is True

    # A member granted elsewhere gets a 404 — never a 403 that confirms the id.
    as_user(client, "nate")
    assert client.get(f"/api/projects/{iid}").status_code == 404
    names = [p["id"] for p in client.get("/api/projects").json()["projects"]]
    assert iid not in names


def test_client_cannot_mutate_structure(client, kit):
    iid = make_project(client, kit)
    sid, oid = add_screen_option(client, iid)
    as_user(client, "cleo")
    assert client.post(f"/api/projects/{iid}/screens",
                       json={"title": "Sneaky"}).status_code == 403
    assert client.post(f"/api/screens/{sid}/options",
                       json={"title": "X"}).status_code == 403
    assert client.post(f"/api/projects/{iid}/send").status_code == 403


def test_send_then_state_walk(client, kit):
    iid = make_project(client, kit)
    _sid, _oid = add_screen_option(client, iid)
    assert client.get(f"/api/projects/{iid}/status").json()["state"] == "in_progress"
    r = client.post(f"/api/projects/{iid}/send")
    assert r.status_code == 200
    assert client.get(f"/api/projects/{iid}/status").json()["state"] == "waiting_client"
    # Sending notified the client side with a turn.
    as_user(client, "cleo")
    bell = client.get("/api/notifications").json()
    assert bell["unread"] == 1
    assert bell["needs_you"] is True
    assert bell["notifications"][0]["kind"] == "turn"


# ------------------------------------------------------------------ selection

def test_selection_is_the_clients_act_and_notifies_the_team(client, kit):
    iid = make_project(client, kit)
    sid, oid = add_screen_option(client, iid)
    client.post(f"/api/projects/{iid}/send")

    as_user(client, "cleo")
    r = client.post(f"/api/screens/{sid}/select", json={"option_id": oid})
    assert r.status_code == 200
    detail = client.get(f"/api/projects/{iid}").json()
    assert detail["screens"][0]["selected_option_id"] == oid
    assert detail["state"] == "done"

    # The manager side heard about it.
    as_user(client, "mara")
    kinds = [n["kind"] for n in client.get("/api/notifications").json()["notifications"]]
    assert "turn" in kinds

    # The undo is the CLIENT'S. Locking a direction without being able to
    # unlock it left the irreversible act in the hands of the person with the
    # least context about what it commits us to.
    as_user(client, "cleo")
    assert client.post(f"/api/screens/{sid}/select",
                       json={"option_id": None}).status_code == 200
    detail = client.get(f"/api/projects/{iid}").json()
    assert detail["screens"][0]["selected_option_id"] is None
    assert detail["state"] == "waiting_client"     # the gate reopens

    # ...and the team is told, so an unlock is never silent.
    as_user(client, "mara")
    bodies = [n["body"] for n in client.get("/api/notifications").json()["notifications"]]
    assert any("unpicked" in b.lower() for b in bodies), bodies

    # The team can still clear one too.
    assert client.post(f"/api/screens/{sid}/select",
                       json={"option_id": None}).status_code == 200


def test_selecting_a_foreign_option_is_rejected(client, kit):
    iid = make_project(client, kit)
    sid, _oid = add_screen_option(client, iid)
    _sid2, oid2 = add_screen_option(client, iid, screen_title="About")
    r = client.post(f"/api/screens/{sid}/select", json={"option_id": oid2})
    assert r.status_code == 400
    assert r.json()["detail"]["error_code"] == "BAD_OPTION"


# ------------------------------------------------------------------ pins

def test_pin_thread_resolve_cycle_drives_rollup(client, kit):
    iid = make_project(client, kit)
    _sid, oid = add_screen_option(client, iid)
    client.post(f"/api/projects/{iid}/send")

    as_user(client, "cleo")
    r = client.post(f"/api/options/{oid}/pins",
                    json={"x_percent": 40.5, "y_percent": 12.2,
                          "body_md": "Can the phone number be bigger?"})
    assert r.status_code == 200, r.text
    pin = r.json()["pin"]
    assert pin["number"] == 1 and pin["resolved"] is False
    # An open client pin puts the ball with the agency.
    assert client.get(f"/api/projects/{iid}/status").json()["state"] == "waiting_agency"

    # The team replies and resolves; the turn returns to the client.
    as_user(client, "mara")
    tid = pin["thread_id"]
    assert client.post(f"/api/threads/{tid}/comments",
                       json={"body_md": "Done — bumped to 1.4rem."}).status_code == 200
    assert client.post(f"/api/threads/{tid}/resolve").status_code == 200
    assert client.get(f"/api/projects/{iid}/status").json()["state"] == "waiting_client"

    # cleo got the reply notification (thread participation).
    as_user(client, "cleo")
    bell = client.get("/api/notifications").json()
    assert any(n["kind"] == "reply" for n in bell["notifications"])
    # And the pin list shows the resolved thread with both comments.
    pins = client.get(f"/api/options/{oid}/pins").json()["pins"]
    assert pins[0]["resolved"] is True
    assert len(pins[0]["comments"]) == 2
    assert pins[0]["comments"][1]["author_side"] == "manager"


def test_mentions_notify_only_project_members(client, kit):
    iid = make_project(client, kit)
    _sid, oid = add_screen_option(client, iid)
    as_user(client, "cleo")
    r = client.post(f"/api/options/{oid}/pins",
                    json={"x_percent": 1, "y_percent": 1,
                          "body_md": "@mara can you look? also @nate @nobody"})
    assert r.status_code == 200
    as_user(client, "mara")
    assert any(n["kind"] == "mention"
               for n in client.get("/api/notifications").json()["notifications"])
    # nate has no access to this project: mention dropped by recipient authority.
    as_user(client, "nate")
    assert client.get("/api/notifications").json()["unread"] == 0


def test_outsider_cannot_touch_pins(client, kit):
    iid = make_project(client, kit)
    _sid, oid = add_screen_option(client, iid)
    as_user(client, "cleo")
    tid = client.post(f"/api/options/{oid}/pins",
                      json={"x_percent": 5, "y_percent": 5,
                            "body_md": "note"}).json()["pin"]["thread_id"]
    as_user(client, "nate")
    assert client.get(f"/api/options/{oid}/pins").status_code == 404
    assert client.post(f"/api/options/{oid}/pins",
                       json={"x_percent": 1, "y_percent": 1,
                             "body_md": "hi"}).status_code == 404
    assert client.post(f"/api/threads/{tid}/comments",
                       json={"body_md": "hi"}).status_code == 404


def test_comment_edit_delete_rules(client, kit):
    iid = make_project(client, kit)
    _sid, oid = add_screen_option(client, iid)
    as_user(client, "cleo")
    pin = client.post(f"/api/options/{oid}/pins",
                      json={"x_percent": 5, "y_percent": 5,
                            "body_md": "original"}).json()["pin"]
    cid = pin["comments"][0]["id"]
    # Author edits; a different non-manager author may not.
    assert client.patch(f"/api/comments/{cid}",
                        json={"body_md": "edited"}).status_code == 200
    as_user(client, "mara")
    assert client.patch(f"/api/comments/{cid}",
                        json={"body_md": "hijack"}).status_code == 403
    # Soft delete by the team; body blanks, row survives.
    assert client.delete(f"/api/comments/{cid}").status_code == 200
    as_user(client, "cleo")
    pins = client.get(f"/api/options/{oid}/pins").json()["pins"]
    assert pins[0]["comments"][0]["deleted"] is True
    assert pins[0]["comments"][0]["body_md"] == ""


# ------------------------------------------------------------- walkthrough

def test_walkthrough_author_approve_rollup(client, kit):
    iid = make_project(client, kit)
    sid, oid = add_screen_option(client, iid)
    r = client.post(f"/api/options/{oid}/walkthrough",
                    json={"title": "The five doors", "body_md": "No hero — the "
                          "practice areas ARE the first screen.",
                          "rect": {"x": 0, "y": 8, "w": 100, "h": 40},
                          "requires_approval": True})
    assert r.status_code == 200
    step_id = r.json()["id"]
    client.post(f"/api/projects/{iid}/send")
    client.post(f"/api/screens/{sid}/select", json={"option_id": oid})
    # Selection alone is not done — the required beat lacks a client approval.
    assert client.get(f"/api/projects/{iid}/status").json()["state"] == "waiting_client"

    # A manager approving their own presentation does not count.
    assert client.post(f"/api/walkthrough/{step_id}/approve", json={}).status_code == 200
    assert client.get(f"/api/projects/{iid}/status").json()["state"] == "waiting_client"

    as_user(client, "cleo")
    wt = client.get(f"/api/options/{oid}/walkthrough").json()["steps"]
    assert wt[0]["client_approved"] is False
    assert client.post(f"/api/walkthrough/{step_id}/approve",
                       json={"comment": "love it"}).status_code == 200
    assert client.get(f"/api/projects/{iid}/status").json()["state"] == "done"

    # Clients cannot author steps.
    assert client.post(f"/api/options/{oid}/walkthrough",
                       json={"title": "sneaky"}).status_code == 403


def test_notifications_mark_read(client, kit):
    iid = make_project(client, kit)
    add_screen_option(client, iid)
    client.post(f"/api/projects/{iid}/send")
    as_user(client, "cleo")
    bell = client.get("/api/notifications").json()
    ids = [n["id"] for n in bell["notifications"]]
    assert bell["unread"] == len(ids) == 1
    assert client.post("/api/notifications/read", json={"ids": ids}).status_code == 200
    bell = client.get("/api/notifications").json()
    assert bell["unread"] == 0 and bell["needs_you"] is False


# ------------------------------------------------- work-in-progress options

def test_an_option_without_a_mockup_is_invisible_to_the_client(client, kit):
    """An option the manager has created but not uploaded into is work in
    progress. The manager needs to see it — that is where the upload control
    lives — but for the client it renders as a dead frame inviting feedback on
    nothing, so it must not appear at all."""
    iid = make_project(client, kit)
    sid, ready = add_screen_option(client, iid)
    r = client.post(f"/api/screens/{sid}/options",
                    json={"title": "Option 2", "concept_tag": "wip"})
    draft = r.json()["id"]

    # Manager: both, and the draft is flagged as having no files.
    opts = client.get(f"/api/projects/{iid}").json()["screens"][0]["options"]
    assert [o["id"] for o in opts] == [ready, draft]
    assert [o["has_files"] for o in opts] == [True, False]

    # Client: only the one that is actually reviewable.
    as_user(client, "cleo")
    detail = client.get(f"/api/projects/{iid}").json()
    assert [o["id"] for o in detail["screens"][0]["options"]] == [ready]


def test_hiding_a_draft_option_is_not_merely_cosmetic(client, kit):
    """Filtering the project view is not enough — the client must not be able to
    reach a draft option by guessing its id either."""
    iid = make_project(client, kit)
    sid, _ready = add_screen_option(client, iid)
    draft = client.post(f"/api/screens/{sid}/options",
                        json={"title": "Option 2", "concept_tag": "wip"}).json()["id"]

    as_user(client, "cleo")
    for path in (f"/api/options/{draft}/view",
                 f"/api/options/{draft}/pins",
                 f"/api/options/{draft}/walkthrough"):
        r = client.get(path)
        assert r.status_code == 404, f"{path} leaked a draft option: {r.status_code}"
        assert r.json()["detail"]["error_code"] == "NO_SUCH_OPTION"

    # And they cannot pin feedback onto one.
    r = client.post(f"/api/options/{draft}/pins",
                    json={"x_percent": 5.0, "y_percent": 5.0, "body_md": "hi"})
    assert r.status_code == 404


def test_a_screen_with_nothing_ready_does_not_appear_for_the_client(client, kit):
    iid = make_project(client, kit)
    add_screen_option(client, iid, "Homepage")
    add_screen_option(client, iid, "About", with_mockup=False)

    assert len(client.get(f"/api/projects/{iid}").json()["screens"]) == 2
    as_user(client, "cleo")
    detail = client.get(f"/api/projects/{iid}").json()
    assert [s["title"] for s in detail["screens"]] == ["Homepage"]
    assert client.get("/api/projects").json()["projects"][0]["screen_count"] == 1


def test_manager_minting_a_view_on_an_empty_option_is_told_why(client, kit):
    """Not a 500 and not a token for a document that does not exist."""
    iid = make_project(client, kit)
    sid, _ = add_screen_option(client, iid)
    draft = client.post(f"/api/screens/{sid}/options",
                        json={"title": "Option 2", "concept_tag": "wip"}).json()["id"]
    r = client.get(f"/api/options/{draft}/view")
    assert r.status_code == 409
    assert r.json()["detail"]["error_code"] == "NO_MOCKUP_YET"


def test_the_happy_path_ends_at_done_not_at_needs_attention(client, kit):
    """End-to-end guard for the D6 correction: a client who reviews, comments,
    gets an answer and picks a direction must land the project on `done` —
    and while their comment is open, on `waiting_agency`, never the alarming
    state."""
    iid = make_project(client, kit)
    sid, oid = add_screen_option(client, iid)
    client.post(f"/api/projects/{iid}/send")

    as_user(client, "cleo")
    pin = client.post(f"/api/options/{oid}/pins", json={
        "x_percent": 40.0, "y_percent": 20.0,
        "body_md": "Can the phone number be bigger?"}).json()["pin"]
    client.post(f"/api/screens/{sid}/select", json={"option_id": oid})
    assert client.get(f"/api/projects/{iid}/status").json()["state"] == "waiting_agency"

    as_user(client, "mara")
    client.post(f"/api/threads/{pin['thread_id']}/comments",
                json={"body_md": "Done — bumped to 22px."})
    client.post(f"/api/threads/{pin['thread_id']}/resolve")
    assert client.get(f"/api/projects/{iid}/status").json()["state"] == "done"


def test_a_pick_unpick_pick_cycle_reports_every_leg(client, kit):
    """The unpick notification is timestamped rather than keyed on the screen, so
    a client changing their mind twice does not have the second unlock silently
    deduped away — the team would be building on a direction that was withdrawn."""
    iid = make_project(client, kit)
    sid, oid = add_screen_option(client, iid)
    client.post(f"/api/projects/{iid}/send")

    as_user(client, "cleo")
    for _ in range(2):
        client.post(f"/api/screens/{sid}/select", json={"option_id": oid})
        client.post(f"/api/screens/{sid}/select", json={"option_id": None})

    as_user(client, "mara")
    bodies = [n["body"] for n in client.get("/api/notifications").json()["notifications"]]
    assert sum(1 for b in bodies if "unpicked" in b.lower()) == 2, bodies


# ------------------------------------------------------- removing an option

def test_removing_an_option_clears_a_selection_pointing_at_it(client, kit):
    """The bug this guards: `db.delete(option)` left `selected_option_id` as a
    dangling id, so the board reported a locked direction that no longer
    existed — and on Postgres the FK would have refused the delete outright."""
    iid = make_project(client, kit)
    sid, oid = add_screen_option(client, iid)
    client.post(f"/api/projects/{iid}/send")
    as_user(client, "cleo")
    client.post(f"/api/screens/{sid}/select", json={"option_id": oid})
    as_user(client, "mara")

    assert client.delete(f"/api/options/{oid}").status_code == 200
    screen = client.get(f"/api/projects/{iid}").json()["screens"][0]
    assert screen["options"] == []
    assert screen["selected_option_id"] is None
    assert screen["selected_variants"] is None


def test_removing_an_option_takes_its_walkthrough_with_it(client, kit):
    """Steps and approvals have no cascade declared; deleting them explicitly is
    what keeps the FK from blocking the delete on Postgres."""
    iid = make_project(client, kit)
    sid, oid = add_screen_option(client, iid)
    step = client.post(f"/api/options/{oid}/walkthrough", json={
        "title": "Beat", "body_md": "…", "target_selector": ".x",
        "requires_approval": True}).json()["id"]
    as_user(client, "cleo")
    client.post(f"/api/walkthrough/{step}/approve", json={})
    as_user(client, "mara")

    assert client.delete(f"/api/options/{oid}").status_code == 200
    assert client.get(f"/api/options/{oid}/walkthrough").status_code == 404


def test_an_option_carrying_feedback_is_not_removable(client, kit):
    """A mis-click must not be able to destroy a client's conversation."""
    iid = make_project(client, kit)
    _sid, oid = add_screen_option(client, iid)
    as_user(client, "cleo")
    client.post(f"/api/options/{oid}/pins",
                json={"x_percent": 5.0, "y_percent": 5.0, "body_md": "Keep this."})
    as_user(client, "mara")

    r = client.delete(f"/api/options/{oid}")
    assert r.status_code == 409
    assert r.json()["detail"]["error_code"] == "HAS_FEEDBACK"
    # Still there, notes intact.
    assert client.get(f"/api/options/{oid}/pins").json()["pins"][0][
        "comments"][0]["body_md"] == "Keep this."


def test_a_resolved_note_still_protects_the_option(client, kit):
    """Resolved is not deleted — the conversation is still the record."""
    iid = make_project(client, kit)
    _sid, oid = add_screen_option(client, iid)
    as_user(client, "cleo")
    tid = client.post(f"/api/options/{oid}/pins", json={
        "x_percent": 5.0, "y_percent": 5.0,
        "body_md": "Done with this."}).json()["pin"]["thread_id"]
    as_user(client, "mara")
    client.post(f"/api/threads/{tid}/resolve")
    assert client.delete(f"/api/options/{oid}").status_code == 409


def test_a_screen_with_feedback_on_any_option_is_not_removable(client, kit):
    iid = make_project(client, kit)
    sid, oid = add_screen_option(client, iid)
    as_user(client, "cleo")
    client.post(f"/api/options/{oid}/pins",
                json={"x_percent": 5.0, "y_percent": 5.0, "body_md": "Note."})
    as_user(client, "mara")
    r = client.delete(f"/api/screens/{sid}")
    assert r.status_code == 409
    assert r.json()["detail"]["error_code"] == "HAS_FEEDBACK"


def test_an_empty_screen_removes_cleanly(client, kit):
    iid = make_project(client, kit)
    sid, _oid = add_screen_option(client, iid)
    assert client.delete(f"/api/screens/{sid}").status_code == 200
    assert client.get(f"/api/projects/{iid}").json()["screens"] == []


def test_a_manager_can_rewrite_the_blurb_the_client_reads(client, kit):
    """The description renders under the title FOR THE CLIENT, so it has to be
    editable in the app — otherwise seeded internal text is stuck there behind
    an owner-only `docker exec`."""
    iid = make_project(client, kit)
    r = client.patch(f"/api/projects/{iid}",
                     json={"description": "Homepage concepts for review."})
    assert r.status_code == 200
    as_user(client, "cleo")
    assert client.get(f"/api/projects/{iid}").json()["description"] == \
        "Homepage concepts for review."
    # ...but only the team may write it.
    assert client.patch(f"/api/projects/{iid}",
                        json={"description": "hacked"}).status_code == 403


# ------------------------------------------------------- the index worklist

def test_needs_you_is_answered_for_the_asker(client, kit):
    """The same project is the TEAM's move when client feedback is open, and the
    CLIENT's when it is their turn. One flag, two answers."""
    iid = make_project(client, kit)
    _sid, oid = add_screen_option(client, iid)
    client.post(f"/api/projects/{iid}/send")

    # Sent, nothing open: the client's move.
    def summary(who):
        # By id: the fixture also creates an "elsewhere" instance, and a manager
        # with all_instances sees both, so [0] is whichever the kit returns first.
        as_user(client, who)
        rows = client.get("/api/projects").json()["projects"]
        return next(r for r in rows if r["id"] == iid)

    assert summary("cleo")["needs_you"] is True
    assert summary("mara")["needs_you"] is False

    # The client leaves a note: now it is ours.
    as_user(client, "cleo")
    client.post(f"/api/options/{oid}/pins",
                json={"x_percent": 5.0, "y_percent": 5.0, "body_md": "A question."})
    assert summary("mara")["needs_you"] is True
    assert summary("cleo")["needs_you"] is False


def test_the_index_counts_open_client_feedback_only(client, kit):
    iid = make_project(client, kit)
    _sid, oid = add_screen_option(client, iid)
    as_user(client, "cleo")
    tid = client.post(f"/api/options/{oid}/pins", json={
        "x_percent": 5.0, "y_percent": 5.0, "body_md": "One."}).json()["pin"]["thread_id"]
    as_user(client, "mara")
    # Our own note is not feedback waiting on us.
    client.post(f"/api/options/{oid}/pins",
                json={"x_percent": 9.0, "y_percent": 9.0, "body_md": "Ours."})
    def mine():
        rows = client.get("/api/projects").json()["projects"]
        return next(r for r in rows if r["id"] == iid)

    assert mine()["open_feedback"] == 1
    # Resolving it clears the number.
    client.post(f"/api/threads/{tid}/resolve")
    assert mine()["open_feedback"] == 0


def test_a_reply_counts_as_activity(client, kit):
    """Comments are not events. A project whose only recent news is a reply must
    not read as idle on the index — that is the project you most want to see."""
    iid = make_project(client, kit)
    _sid, oid = add_screen_option(client, iid)
    def activity():
        rows = client.get("/api/projects").json()["projects"]
        return next(r for r in rows if r["id"] == iid)["last_activity"]

    before = activity()
    assert before is not None

    as_user(client, "cleo")
    client.post(f"/api/options/{oid}/pins",
                json={"x_percent": 5.0, "y_percent": 5.0, "body_md": "Later note."})
    assert activity() >= before


def test_a_beat_carries_its_demo_click_through_the_api(client, kit):
    """The spotlight target and the click target are separate on purpose: you
    often point at one element and click another."""
    iid = make_project(client, kit)
    _sid, oid = add_screen_option(client, iid)
    client.post(f"/api/options/{oid}/walkthrough", json={
        "title": "Try this: click Injuries", "body_md": "The grid becomes theirs.",
        "target_selector": ".triage", "click_selector": ".tcell.injuries",
        "click_dismiss_seconds": 5, "requires_approval": False})

    as_user(client, "cleo")
    step = client.get(f"/api/options/{oid}/walkthrough").json()["steps"][0]
    assert step["click_selector"] == ".tcell.injuries"
    assert step["click_dismiss_seconds"] == 5
    assert step["target_selector"] == ".triage"


def test_a_demo_click_can_be_edited_and_turned_off(client, kit):
    iid = make_project(client, kit)
    _sid, oid = add_screen_option(client, iid)
    sid_ = client.post(f"/api/options/{oid}/walkthrough", json={
        "title": "Beat", "body_md": "…", "target_selector": ".x",
        "click_selector": ".a", "requires_approval": False}).json()["id"]

    client.patch(f"/api/walkthrough/{sid_}",
                 json={"click_selector": "", "click_dismiss_seconds": 0})
    step = client.get(f"/api/options/{oid}/walkthrough").json()["steps"][0]
    assert (step["click_selector"], step["click_dismiss_seconds"]) == ("", 0)


# ------------------------------------------------------- shell/asset caching

def test_spa_shell_revalidates_and_assets_are_immutable(client):
    """A deploy must actually reach a returning browser.

    The shell carried no Cache-Control — only an ETag — so browsers applied
    heuristic caching and kept serving an old index.html, which references the
    OLD content-hashed bundle. The result was a deployed fix that stayed
    invisible on a normal reload while /api/meta reported the new version.
    The shell must revalidate; hashed assets can cache forever."""
    shell = client.get("/some/deep/link")
    assert shell.status_code == 200
    assert shell.headers.get("cache-control") == "no-cache"

    # Assets are only mounted when a build is present (not in a bare checkout).
    from app.main import STATIC_DIR
    if (STATIC_DIR / "assets").is_dir():
        asset = next((STATIC_DIR / "assets").iterdir(), None)
        if asset is not None:
            r = client.get(f"/assets/{asset.name}")
            assert r.status_code == 200
            assert "immutable" in r.headers.get("cache-control", "")


def test_a_note_can_be_retracted_marker_and_all(client, kit):
    """Removing a COMMENT is soft — a conversation is a record. Removing the PIN
    is a different act: it withdraws the note itself. A marker left on the design
    with nothing behind it is worse than either."""
    iid = make_project(client, kit)
    _sid, oid = add_screen_option(client, iid)
    client.post(f"/api/projects/{iid}/send")
    as_user(client, "cleo")
    pin = client.post(f"/api/options/{oid}/pins", json={
        "x_percent": 5.0, "y_percent": 5.0, "body_md": "test"}).json()["pin"]
    # Edit it first, so there is a revision hanging off the comment too.
    client.patch(f"/api/comments/{pin['comments'][0]['id']}",
                 json={"body_md": "test 2"})

    assert client.delete(f"/api/pins/{pin['pin_id']}").status_code == 200
    assert client.get(f"/api/options/{oid}/pins").json()["pins"] == []
    # Nothing left behind: the board is clean and the turn goes back.
    assert client.get(f"/api/projects/{iid}/status").json()["state"] == "waiting_client"
    assert client.get(f"/api/projects/{iid}").json()["screens"][0][
        "options"][0]["open_pin_count"] == 0


def test_only_the_author_or_the_team_can_retract_a_note(client, kit):
    iid = make_project(client, kit)
    _sid, oid = add_screen_option(client, iid)
    as_user(client, "mara")
    ours = client.post(f"/api/options/{oid}/pins", json={
        "x_percent": 5.0, "y_percent": 5.0, "body_md": "Ours."}).json()["pin"]
    as_user(client, "cleo")
    theirs = client.post(f"/api/options/{oid}/pins", json={
        "x_percent": 9.0, "y_percent": 9.0, "body_md": "Theirs."}).json()["pin"]

    assert client.delete(f"/api/pins/{ours['pin_id']}").status_code == 403
    assert client.delete(f"/api/pins/{theirs['pin_id']}").status_code == 200
    as_user(client, "mara")
    assert client.delete(f"/api/pins/{ours['pin_id']}").status_code == 200
