"""Tests for AgentDrop.send() large-file -> presigned direct-to-R2 upload.

Context: the multipart path is capped at 100 MB by the backend multer config
and is memory-bound because it buffers the whole file in Railway's process RAM.
Files larger than the PRESIGNED_UPLOAD_THRESHOLD must bypass Railway entirely
and upload directly to R2 via presigned URLs (same pattern the dashboard uses
for human-to-agent uploads).

Contract under test:
  1. send() routes to multipart when total file size is under threshold.
  2. send() routes to presigned when total file size is >= threshold.
  3. The presigned flow: resolve -> POST /presigned -> PUT to R2 -> POST /confirm.
  4. Files are encrypted locally; ciphertext, not plaintext, is PUT to R2.
  5. agent-to-human mode skips encryption even on the presigned path.
  6. Recipient can roundtrip-decrypt the ciphertext using their private key.
  7. Error paths propagate AgentDropError.

Run:  pytest tests/test_send_presigned.py
"""

import base64
import json
import os
from pathlib import Path
from unittest.mock import patch, MagicMock

import pytest

from agentdrop.client import (
    AgentDrop,
    AgentDropError,
    PRESIGNED_UPLOAD_THRESHOLD,
)
from agentdrop.crypto import KeyPair, decrypt_file, decrypt_symmetric_key


# ─── Helpers ──────────────────────────────────────────────────────


def _setup_client(tmp_path: Path):
    """Build a configured client with known keys. Returns (client, sender_keys, recipient_keys)."""
    config_dir = tmp_path / ".agentdrop"
    config_dir.mkdir(parents=True, exist_ok=True)

    sender_keys = KeyPair.generate()
    recipient_keys = KeyPair.generate()

    (config_dir / "config.json").write_text(
        json.dumps(
            {
                "agent_id": "sender-agent",
                "agent_uuid": "00000000-0000-0000-0000-000000000001",
                "private_key": sender_keys.private_key_b64,
                "public_key": sender_keys.public_key_b64,
                "api_key": "agd_test_presigned_key",
            }
        )
    )

    client = AgentDrop(
        api_key="agd_test_presigned_key",
        api_base="https://test.agentdrop.io",
        shield=False,
        config_dir=str(config_dir),
    )
    return client, sender_keys, recipient_keys


def _make_big_file(path: Path, size: int) -> None:
    """Write a pseudo-random file of the given size."""
    chunk = os.urandom(1024)
    with open(path, "wb") as f:
        written = 0
        while written < size:
            to_write = min(len(chunk), size - written)
            f.write(chunk[:to_write])
            written += to_write


class RecordedCall:
    __slots__ = ("url", "method", "json_body", "raw_body", "headers")

    def __init__(self, url: str, method: str, json_body=None, raw_body=None, headers=None):
        self.url = url
        self.method = method
        self.json_body = json_body
        self.raw_body = raw_body
        self.headers = headers or {}


def _make_mock_response(status: int, body):
    resp = MagicMock()
    resp.status_code = status
    if isinstance(body, (dict, list)):
        resp.json.return_value = body
        resp.text = json.dumps(body)
    else:
        resp.json.side_effect = ValueError("not json")
        resp.text = body or ""
    return resp


def _build_presigned_stubs(
    calls: list[RecordedCall],
    recipient_keys: KeyPair,
    transfer_id: str = "txfr_py_presigned",
    r2_base: str = "https://r2.mock.agentdrop.io/upload",
    overrides: dict | None = None,
):
    """Build (session_post, session_request, requests_put) stubs that record calls."""
    overrides = overrides or {}

    def session_get_stub(url, *args, **kwargs):
        # Covers the resolve call via _request
        if "/v1/agents/resolve" in url:
            calls.append(RecordedCall(url, "GET"))
            return _make_mock_response(
                200,
                {
                    "id": "00000000-0000-0000-0000-000000000002",
                    "agent_uuid": "00000000-0000-0000-0000-000000000002",
                    "agent_id": "recipient-agent",
                    "public_key": recipient_keys.public_key_b64,
                },
            )
        if "/v1/channels" in url:
            calls.append(RecordedCall(url, "GET"))
            return _make_mock_response(404, {"error": {"code": "NOT_FOUND"}})
        calls.append(RecordedCall(url, "GET"))
        return _make_mock_response(418, {"error": {"message": f"unmocked GET {url}"}})

    def session_post_stub(url, *args, **kwargs):
        json_body = kwargs.get("json")
        if url.endswith("/v1/transfers/presigned"):
            status = overrides.get("presigned_status", 201)
            if status >= 400:
                calls.append(
                    RecordedCall(url, "POST", json_body=json_body)
                )
                return _make_mock_response(
                    status,
                    {"error": {"code": "FILE_TOO_LARGE", "message": "mock fail"}},
                )
            files = (json_body or {}).get("files", [])
            upload_urls = [
                {
                    "file_id": f"file_{i}",
                    "file_name": f["name"],
                    "upload_url": f"{r2_base}/{transfer_id}/{i}-{f['name']}",
                    "method": "PUT",
                    "headers": {"Content-Type": "application/octet-stream"},
                }
                for i, f in enumerate(files)
            ]
            calls.append(RecordedCall(url, "POST", json_body=json_body))
            return _make_mock_response(
                status,
                {
                    "id": transfer_id,
                    "upload_urls": upload_urls,
                    "confirm_url": f"https://test.agentdrop.io/v1/transfers/{transfer_id}/confirm",
                    "expires_at": "2026-04-20T12:00:00Z",
                },
            )
        if url.endswith(f"/v1/transfers/{transfer_id}/confirm"):
            status = overrides.get("confirm_status", 200)
            calls.append(RecordedCall(url, "POST", json_body=json_body))
            return _make_mock_response(
                status, {"id": transfer_id, "status": "active"}
            )
        if url.endswith("/v1/transfers"):
            # multipart fallback (should not be hit on presigned path)
            calls.append(RecordedCall(url, "POST"))
            return _make_mock_response(
                201, {"id": "txfr_small_multipart", "status": "active"}
            )
        calls.append(RecordedCall(url, "POST", json_body=json_body))
        return _make_mock_response(418, {"error": {"message": f"unmocked POST {url}"}})

    def requests_put_stub(url, *args, **kwargs):
        status = overrides.get("r2_put_status", 200)
        calls.append(
            RecordedCall(
                url, "PUT", raw_body=kwargs.get("data"), headers=kwargs.get("headers")
            )
        )
        return _make_mock_response(status, "")

    return session_get_stub, session_post_stub, requests_put_stub


# ─── Threshold constant sanity check ──────────────────────────────


def test_presigned_upload_threshold_is_50mb():
    assert PRESIGNED_UPLOAD_THRESHOLD == 50 * 1024 * 1024


# ─── Routing decision ─────────────────────────────────────────────


def test_send_small_file_uses_multipart_path(tmp_path):
    client, _, recipient_keys = _setup_client(tmp_path)
    small_file = tmp_path / "tiny.txt"
    small_file.write_bytes(b"hi")

    calls: list[RecordedCall] = []
    session_get, session_post, requests_put = _build_presigned_stubs(
        calls, recipient_keys
    )

    with patch.object(client._session, "get", side_effect=session_get), \
         patch.object(client._session, "post", side_effect=session_post), \
         patch("agentdrop.client.requests.put", side_effect=requests_put), \
         patch.object(client, "_request", side_effect=lambda m, u, **kw: session_get(u).json()):
        client.send("recipient-agent", [str(small_file)])

    presigned_hits = [c for c in calls if c.url.endswith("/v1/transfers/presigned")]
    multipart_hits = [c for c in calls if c.url.endswith("/v1/transfers") and c.method == "POST"]
    assert len(presigned_hits) == 0
    assert len(multipart_hits) == 1


def test_send_large_file_uses_presigned_path(tmp_path):
    client, _, recipient_keys = _setup_client(tmp_path)
    big_file = tmp_path / "big.bin"
    _make_big_file(big_file, PRESIGNED_UPLOAD_THRESHOLD + 1024)

    calls: list[RecordedCall] = []
    session_get, session_post, requests_put = _build_presigned_stubs(
        calls, recipient_keys
    )

    with patch.object(client._session, "get", side_effect=session_get), \
         patch.object(client._session, "post", side_effect=session_post), \
         patch("agentdrop.client.requests.put", side_effect=requests_put), \
         patch.object(client, "_request", side_effect=lambda m, u, **kw: session_get(u).json()):
        result = client.send("recipient-agent", [str(big_file)])

    presigned_hits = [c for c in calls if c.url.endswith("/v1/transfers/presigned")]
    multipart_post = [c for c in calls if c.url.endswith("/v1/transfers") and c.method == "POST"]
    put_hits = [c for c in calls if c.method == "PUT"]
    confirm_hits = [c for c in calls if c.url.endswith("/confirm")]

    assert len(presigned_hits) == 1
    assert len(multipart_post) == 0
    assert len(put_hits) == 1
    assert len(confirm_hits) == 1
    assert result["id"] == "txfr_py_presigned"


# ─── Happy-path presigned flow ────────────────────────────────────


def test_presigned_body_contains_file_metadata_and_iv(tmp_path):
    client, _, recipient_keys = _setup_client(tmp_path)
    big_file = tmp_path / "payload.bin"
    _make_big_file(big_file, PRESIGNED_UPLOAD_THRESHOLD + 256)

    calls: list[RecordedCall] = []
    session_get, session_post, requests_put = _build_presigned_stubs(
        calls, recipient_keys
    )

    with patch.object(client._session, "get", side_effect=session_get), \
         patch.object(client._session, "post", side_effect=session_post), \
         patch("agentdrop.client.requests.put", side_effect=requests_put), \
         patch.object(client, "_request", side_effect=lambda m, u, **kw: session_get(u).json()):
        client.send("recipient-agent", [str(big_file)], message="large payload")

    presigned = next(
        c for c in calls if c.url.endswith("/v1/transfers/presigned")
    )
    body = presigned.json_body
    assert body["sender"] == "sender-agent"
    assert body["recipient"] == "recipient-agent"
    assert body["mode"] == "agent-to-agent"
    assert body["message"] == "large payload"
    assert body["is_encrypted"] is True
    assert isinstance(body["encrypted_key"], str)
    assert len(body["files"]) == 1
    assert body["files"][0]["name"] == "payload.bin"
    assert body["files"][0]["size"] >= PRESIGNED_UPLOAD_THRESHOLD
    assert body["files"][0]["encryption_iv"]


def test_presigned_upload_uploads_ciphertext_not_plaintext(tmp_path):
    client, _, recipient_keys = _setup_client(tmp_path)
    big_file = tmp_path / "payload.bin"
    _make_big_file(big_file, PRESIGNED_UPLOAD_THRESHOLD + 256)
    plaintext = big_file.read_bytes()

    calls: list[RecordedCall] = []
    session_get, session_post, requests_put = _build_presigned_stubs(
        calls, recipient_keys
    )

    with patch.object(client._session, "get", side_effect=session_get), \
         patch.object(client._session, "post", side_effect=session_post), \
         patch("agentdrop.client.requests.put", side_effect=requests_put), \
         patch.object(client, "_request", side_effect=lambda m, u, **kw: session_get(u).json()):
        client.send("recipient-agent", [str(big_file)])

    put_call = next(c for c in calls if c.method == "PUT")
    uploaded_bytes = put_call.raw_body
    assert uploaded_bytes != plaintext
    # AES-GCM ciphertext = plaintext + 16 byte auth tag
    assert len(uploaded_bytes) == len(plaintext) + 16


def test_recipient_can_decrypt_the_roundtrip(tmp_path):
    client, sender_keys, recipient_keys = _setup_client(tmp_path)
    big_file = tmp_path / "payload.bin"
    _make_big_file(big_file, PRESIGNED_UPLOAD_THRESHOLD + 256)
    plaintext = big_file.read_bytes()

    calls: list[RecordedCall] = []
    session_get, session_post, requests_put = _build_presigned_stubs(
        calls, recipient_keys
    )

    with patch.object(client._session, "get", side_effect=session_get), \
         patch.object(client._session, "post", side_effect=session_post), \
         patch("agentdrop.client.requests.put", side_effect=requests_put), \
         patch.object(client, "_request", side_effect=lambda m, u, **kw: session_get(u).json()):
        client.send("recipient-agent", [str(big_file)])

    presigned = next(
        c for c in calls if c.url.endswith("/v1/transfers/presigned")
    )
    put_call = next(c for c in calls if c.method == "PUT")

    encrypted_key_b64 = presigned.json_body["encrypted_key"]
    iv = base64.b64decode(presigned.json_body["files"][0]["encryption_iv"])
    ciphertext = put_call.raw_body

    sym_key = decrypt_symmetric_key(
        encrypted_key_b64, recipient_keys, sender_keys.public_key_b64
    )
    decrypted = decrypt_file(ciphertext, sym_key, iv)
    assert decrypted == plaintext


def test_agent_to_human_skips_encryption_on_presigned(tmp_path):
    client, _, recipient_keys = _setup_client(tmp_path)
    big_file = tmp_path / "report.pdf"
    _make_big_file(big_file, PRESIGNED_UPLOAD_THRESHOLD + 256)
    plaintext = big_file.read_bytes()

    calls: list[RecordedCall] = []
    session_get, session_post, requests_put = _build_presigned_stubs(
        calls, recipient_keys
    )

    with patch.object(client._session, "get", side_effect=session_get), \
         patch.object(client._session, "post", side_effect=session_post), \
         patch("agentdrop.client.requests.put", side_effect=requests_put), \
         patch.object(client, "_request", side_effect=lambda m, u, **kw: session_get(u).json()):
        client.send("human@example.com", [str(big_file)], mode="agent-to-human")

    presigned = next(
        c for c in calls if c.url.endswith("/v1/transfers/presigned")
    )
    put_call = next(c for c in calls if c.method == "PUT")

    assert not presigned.json_body.get("is_encrypted")
    assert put_call.raw_body == plaintext  # unencrypted


# ─── Error paths ──────────────────────────────────────────────────


def test_presigned_endpoint_error_raises(tmp_path):
    client, _, recipient_keys = _setup_client(tmp_path)
    big_file = tmp_path / "big.bin"
    _make_big_file(big_file, PRESIGNED_UPLOAD_THRESHOLD + 256)

    calls: list[RecordedCall] = []
    session_get, session_post, requests_put = _build_presigned_stubs(
        calls, recipient_keys, overrides={"presigned_status": 413}
    )

    with patch.object(client._session, "get", side_effect=session_get), \
         patch.object(client._session, "post", side_effect=session_post), \
         patch("agentdrop.client.requests.put", side_effect=requests_put), \
         patch.object(client, "_request", side_effect=lambda m, u, **kw: session_get(u).json()):
        with pytest.raises(AgentDropError):
            client.send("recipient-agent", [str(big_file)])


def test_r2_put_error_raises_and_does_not_confirm(tmp_path):
    client, _, recipient_keys = _setup_client(tmp_path)
    big_file = tmp_path / "big.bin"
    _make_big_file(big_file, PRESIGNED_UPLOAD_THRESHOLD + 256)

    calls: list[RecordedCall] = []
    session_get, session_post, requests_put = _build_presigned_stubs(
        calls, recipient_keys, overrides={"r2_put_status": 500}
    )

    with patch.object(client._session, "get", side_effect=session_get), \
         patch.object(client._session, "post", side_effect=session_post), \
         patch("agentdrop.client.requests.put", side_effect=requests_put), \
         patch.object(client, "_request", side_effect=lambda m, u, **kw: session_get(u).json()):
        with pytest.raises(AgentDropError):
            client.send("recipient-agent", [str(big_file)])

    confirm_hits = [c for c in calls if c.url.endswith("/confirm")]
    assert len(confirm_hits) == 0


def test_confirm_error_raises(tmp_path):
    client, _, recipient_keys = _setup_client(tmp_path)
    big_file = tmp_path / "big.bin"
    _make_big_file(big_file, PRESIGNED_UPLOAD_THRESHOLD + 256)

    calls: list[RecordedCall] = []
    session_get, session_post, requests_put = _build_presigned_stubs(
        calls, recipient_keys, overrides={"confirm_status": 500}
    )

    with patch.object(client._session, "get", side_effect=session_get), \
         patch.object(client._session, "post", side_effect=session_post), \
         patch("agentdrop.client.requests.put", side_effect=requests_put), \
         patch.object(client, "_request", side_effect=lambda m, u, **kw: session_get(u).json()):
        with pytest.raises(AgentDropError):
            client.send("recipient-agent", [str(big_file)])


def test_missing_file_raises_before_any_network_call(tmp_path):
    client, _, recipient_keys = _setup_client(tmp_path)

    calls: list[RecordedCall] = []
    session_get, session_post, requests_put = _build_presigned_stubs(
        calls, recipient_keys
    )

    with patch.object(client._session, "get", side_effect=session_get), \
         patch.object(client._session, "post", side_effect=session_post), \
         patch("agentdrop.client.requests.put", side_effect=requests_put), \
         patch.object(client, "_request", side_effect=lambda m, u, **kw: session_get(u).json()):
        with pytest.raises(AgentDropError, match="File not found"):
            client.send("recipient-agent", [str(tmp_path / "does-not-exist.bin")])

    assert calls == []
