"""M4 conversation behavior: comment CRUD (author-gated edit/delete,
backdating), mention parsing, SSE publish, and the attachment upload/link/
serve pipeline (mime + size caps, traversal guard) — plan §5/§6.5,
review-patterns-ref §2.6.1/§2.7/§3.5."""
import datetime
import io

from app import constants, events
from app.config import settings
from app.models import Ball, Court, Match, Side, TeamMember, TeamNode
from app.services import attachments as attachments_service
from tests.conftest import make_player, mint_client_for


def _setup(db):
    """One ball on one court, two players sharing a team node (so both have
    an acting_node_id to stamp) — enough surface for the conversation on
    top of it."""
    rian = make_player(db, "rian", display_name="Rian", email="rian@rian.ca")
    erin = make_player(db, "erin", display_name="Erin", email="erin@brentwood.example")

    match = Match(slug="convomatch", name="Convo Match")
    db.add(match)
    db.flush()
    side = Side(match_id=match.id, name="Bowden Works", position=0)
    db.add(side)
    db.flush()
    node = TeamNode(side_id=side.id, name="Front line", boundary_player_id=rian.id)
    db.add(node)
    db.flush()
    db.add(TeamMember(match_id=match.id, node_id=node.id, player_id=rian.id))
    db.add(TeamMember(match_id=match.id, node_id=node.id, player_id=erin.id))

    court = Court(
        match_id=match.id,
        config_key="pages",
        name="Pages",
        position=0,
        config=constants.COURT_CONFIGS["pages"],
    )
    db.add(court)
    db.flush()

    ball = Ball(
        court_id=court.id, title="Home", status="open", review_state="not ready",
        author_id=rian.id,
    )
    db.add(ball)
    db.commit()
    return {
        "rian": rian, "erin": erin, "match": match, "court": court,
        "ball": ball, "node": node,
    }


# --------------------------------------------------------------- comments


def test_create_comment_stamps_author_and_node(client, db_session):
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    resp = c.post(f"/api/balls/{b['ball'].id}/comments", json={"body": "First!"})
    assert resp.status_code == 201, resp.text
    body = resp.json()
    assert body["ball_id"] == str(b["ball"].id)
    assert body["author_id"] == str(b["rian"].id)
    assert body["acting_node_id"] == str(b["node"].id)
    assert body["body"] == "First!"
    assert body["edited_at"] is None
    assert body["attachments"] == []


def test_list_comments_orders_by_created_at_not_insertion(client, db_session):
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    now = datetime.datetime.now(datetime.timezone.utc)
    # Posted out of chronological order (via the created_at override) — the
    # LIST must sort by created_at, proving it isn't just insertion order.
    c.post(
        f"/api/balls/{b['ball'].id}/comments",
        json={"body": "middle", "created_at": now.isoformat()},
    )
    c.post(
        f"/api/balls/{b['ball'].id}/comments",
        json={"body": "last", "created_at": (now + datetime.timedelta(hours=1)).isoformat()},
    )
    c.post(
        f"/api/balls/{b['ball'].id}/comments",
        json={"body": "first", "created_at": (now - datetime.timedelta(hours=1)).isoformat()},
    )

    resp = c.get(f"/api/balls/{b['ball'].id}/comments")
    assert resp.status_code == 200
    assert [row["body"] for row in resp.json()] == ["first", "middle", "last"]


def test_patch_backdates_created_at_without_marking_edited(client, db_session):
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    created = c.post(f"/api/balls/{b['ball'].id}/comments", json={"body": "logged late"}).json()

    yesterday = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)
    resp = c.patch(f"/api/comments/{created['id']}", json={"created_at": yesterday.isoformat()})
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["created_at"].startswith(str(yesterday.date()))
    # Backdating alone isn't "this text changed" — no edited_at stamp.
    assert body["edited_at"] is None


def test_patch_body_stamps_edited_at(client, db_session):
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    created = c.post(f"/api/balls/{b['ball'].id}/comments", json={"body": "typo"}).json()

    resp = c.patch(f"/api/comments/{created['id']}", json={"body": "fixed"})
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["body"] == "fixed"
    assert body["edited_at"] is not None


def test_author_gate_blocks_non_author_edit_and_delete(client, db_session):
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    created = c.post(f"/api/balls/{b['ball'].id}/comments", json={"body": "mine"}).json()

    c2 = mint_client_for(client, "erin")
    resp = c2.patch(f"/api/comments/{created['id']}", json={"body": "hijacked"})
    assert resp.status_code == 403
    assert resp.json()["error_code"] == "AUTHOR_ONLY"

    resp = c2.delete(f"/api/comments/{created['id']}")
    assert resp.status_code == 403
    assert resp.json()["error_code"] == "AUTHOR_ONLY"

    # the real author can still edit and delete their own comment
    c3 = mint_client_for(client, "rian")
    resp = c3.patch(f"/api/comments/{created['id']}", json={"body": "mine, edited"})
    assert resp.status_code == 200, resp.text
    resp = c3.delete(f"/api/comments/{created['id']}")
    assert resp.status_code == 200
    assert resp.json() == {"ok": True}


def test_create_comment_parses_mentions(client, db_session):
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    resp = c.post(
        f"/api/balls/{b['ball'].id}/comments",
        json={"body": "cc @erin@brentwood.example and @ghost@nowhere.example, thanks"},
    )
    assert resp.status_code == 201, resp.text
    # order-preserving; no lookup against `players` — ghost@nowhere.example
    # matches no Player row and still comes back (resolving mentions against
    # real users is M5, not this parse step).
    assert resp.json()["mentions"] == ["erin@brentwood.example", "ghost@nowhere.example"]

    # a bare "@name" isn't a full email -> the server-side regex (ref
    # §2.6.1) doesn't match it, unlike the client's looser caret-token
    # detection (ref §3.4) used while typing.
    resp2 = c.post(f"/api/balls/{b['ball'].id}/comments", json={"body": "hey @rian"})
    assert resp2.json()["mentions"] == []


def test_create_comment_publishes_sse(client, db_session, monkeypatch):
    published = []
    monkeypatch.setattr(events, "publish", lambda evt: published.append(evt))
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    resp = c.post(f"/api/balls/{b['ball'].id}/comments", json={"body": "hello"})
    assert resp.status_code == 201, resp.text
    assert published == [
        {"type": "comment.created", "ball_id": str(b["ball"].id), "comment_id": resp.json()["id"]}
    ]


def test_unknown_ball_and_comment_404(client, db_session):
    c = mint_client_for(client, "rian")
    resp = c.get("/api/balls/00000000-0000-0000-0000-000000000000/comments")
    assert resp.status_code == 404
    assert resp.json()["error_code"] == "BALL_NOT_FOUND"

    resp = c.patch("/api/comments/00000000-0000-0000-0000-000000000000", json={"body": "x"})
    assert resp.status_code == 404
    assert resp.json()["error_code"] == "COMMENT_NOT_FOUND"


# -------------------------------------------------------------- attachments


def test_upload_rejects_disallowed_mime(client, tmp_path, monkeypatch):
    monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
    c = mint_client_for(client, "rian")
    resp = c.post(
        "/api/attachments",
        files={"file": ("note.txt", io.BytesIO(b"hello"), "text/plain")},
    )
    assert resp.status_code == 415
    assert resp.json()["error_code"] == "INVALID_MIME"
    assert list(tmp_path.iterdir()) == []  # nothing written to disk


def test_upload_rejects_oversized_file(client, tmp_path, monkeypatch):
    monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
    monkeypatch.setattr(attachments_service, "MAX_UPLOAD_BYTES", 10)
    c = mint_client_for(client, "rian")
    resp = c.post(
        "/api/attachments",
        files={"file": ("big.png", io.BytesIO(b"x" * 11), "image/png")},
    )
    assert resp.status_code == 413
    assert resp.json()["error_code"] == "FILE_TOO_LARGE"


def test_validate_stored_filename_rejects_traversal_shapes():
    """Direct unit coverage of the one shared guard (services.attachments
    .validate_stored_filename) — both GET routes and services.conversation's
    attachment-linking step funnel through this, so this is the security-
    critical function to pin down precisely."""
    for bad in ("../secret.png", "a/b.png", "a\\b.png", "..", ".", ""):
        try:
            attachments_service.validate_stored_filename(bad)
        except ValueError:
            continue
        raise AssertionError(f"expected {bad!r} to be rejected")
    attachments_service.validate_stored_filename("a-real-uuid.png")  # does not raise


def test_attachment_by_path_rejects_traversal(client, tmp_path, monkeypatch):
    monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
    c = mint_client_for(client, "rian")
    # A literal backslash needs no URL percent-encoding trickery to reach
    # the handler unmangled, so this exercises the real HTTP path.
    resp = c.get("/api/attachments/by-path/..\\secret.png")
    assert resp.status_code == 400
    assert resp.json()["error_code"] == "INVALID_FILE_PATH"


def test_create_comment_rejects_attachment_path_traversal(client, db_session, tmp_path, monkeypatch):
    monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    resp = c.post(
        f"/api/balls/{b['ball'].id}/comments",
        json={
            "body": "sneaky",
            "attachments": [{"file_path": "../../etc/passwd", "mime": "image/png", "bytes": 1}],
        },
    )
    assert resp.status_code == 400
    assert resp.json()["error_code"] == "INVALID_ATTACHMENT_PATH"


def test_create_comment_rejects_unstaged_attachment(client, db_session, tmp_path, monkeypatch):
    """A file_path that validates but was never actually uploaded (no file
    on disk) must not silently create a dangling Attachment row."""
    monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    resp = c.post(
        f"/api/balls/{b['ball'].id}/comments",
        json={
            "body": "never uploaded",
            "attachments": [{"file_path": "nonexistent.png", "mime": "image/png", "bytes": 1}],
        },
    )
    assert resp.status_code == 400
    assert resp.json()["error_code"] == "ATTACHMENT_NOT_FOUND"


def test_comment_with_attachment_round_trip(client, db_session, tmp_path, monkeypatch):
    monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
    b = _setup(db_session)
    c = mint_client_for(client, "rian")

    raw = b"\x89PNG-fake-bytes"
    upload = c.post(
        "/api/attachments",
        files={"file": ("screenshot.png", io.BytesIO(raw), "image/png")},
    )
    assert upload.status_code == 200, upload.text
    staged = upload.json()
    assert staged["mime"] == "image/png"
    assert staged["bytes"] == len(raw)
    assert "/" not in staged["file_path"] and "\\" not in staged["file_path"]

    resp = c.post(
        f"/api/balls/{b['ball'].id}/comments",
        json={"body": "see attached", "attachments": [staged]},
    )
    assert resp.status_code == 201, resp.text
    body = resp.json()
    assert len(body["attachments"]) == 1
    att = body["attachments"][0]
    assert att["file_path"] == staged["file_path"]
    assert att["mime"] == "image/png"
    assert att["bytes"] == staged["bytes"]

    fetched = c.get(f"/api/attachments/{att['id']}")
    assert fetched.status_code == 200
    assert fetched.content == raw
    assert fetched.headers["content-type"] == "image/png"

    # GET list also carries the attachment (batched query, not N+1).
    listed = c.get(f"/api/balls/{b['ball'].id}/comments").json()
    assert listed[0]["attachments"][0]["file_path"] == staged["file_path"]


def test_delete_comment_removes_attachment_file_and_row(client, db_session, tmp_path, monkeypatch):
    monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    upload = c.post(
        "/api/attachments",
        files={"file": ("x.png", io.BytesIO(b"bytes"), "image/png")},
    ).json()
    created = c.post(
        f"/api/balls/{b['ball'].id}/comments",
        json={"body": "temp", "attachments": [upload]},
    ).json()
    disk_path = tmp_path / upload["file_path"]
    assert disk_path.is_file()

    resp = c.delete(f"/api/comments/{created['id']}")
    assert resp.status_code == 200
    assert not disk_path.exists()
    assert c.get(f"/api/balls/{b['ball'].id}/comments").json() == []
