"""
Tests for the FDE Python SDK.

Covers:
  1. HMAC signing algorithm matches the server implementation.
  2. JWT generation produces correct claims.
  3. Request building attaches the required auth headers.
  4. HTTP error responses raise FdeApiError.
  5. evaluate_risk / submit_outcome / submit_labels round-trips (mocked).
"""

from __future__ import annotations

import hashlib
import hmac as _hmac
import json
import time
from unittest.mock import AsyncMock, MagicMock, patch

import jwt
import pytest

from fde_sdk.client import FdeApiError, FdeClient
from fde_sdk.types import (
    CustKV,
    EnhancedDecisionItem,
    EvaluateRequest,
    EventDetailModel,
    LoginDetail,
    LoginResultDetail,
    OutcomeRequest,
    ResultStatus,
    SubmitLabelsRequest,
    TransactionDetail,
    UserInfoModel,
)

# ---------------------------------------------------------------------------
# Test constants  (not real credentials)
# ---------------------------------------------------------------------------

SIGNING_VAL = "test-hs256-unit-signing-val"
HMAC_HEX = "deadbeefcafebabe" * 4  # 64 hex chars = 32 bytes
CLIENT_ID = "test-client"
ENDPOINT = "https://fde.example.com"


def make_client() -> FdeClient:
    return FdeClient(
        endpoint=ENDPOINT,
        jwt_signing_key=SIGNING_VAL,
        hmac_key=HMAC_HEX,
        client_id=CLIENT_ID,
    )


def _ref_hmac(hmac_hex: str, timestamp: str, body_bytes: bytes) -> str:
    """Reference HMAC impl mirroring app/auth/hmac_auth.py."""
    body_hash = hashlib.sha256(body_bytes).hexdigest()
    sign_string = f"{timestamp}\n{body_hash}"
    return _hmac.new(
        bytes.fromhex(hmac_hex), sign_string.encode(), hashlib.sha256
    ).hexdigest()


# ---------------------------------------------------------------------------
# 1. HMAC algorithm correctness
# ---------------------------------------------------------------------------


def test_hmac_matches_server_reference():
    """SDK _compute_hmac must equal the server reference implementation."""
    client = make_client()
    body = b'{"eventCode":"login","sessionId":"s1"}'
    ts = "1700000000"
    assert client._compute_hmac(ts, body) == _ref_hmac(HMAC_HEX, ts, body)


def test_hmac_changes_with_body():
    client = make_client()
    ts = "1700000001"
    assert client._compute_hmac(ts, b"body-a") != client._compute_hmac(ts, b"body-b")


def test_hmac_changes_with_timestamp():
    client = make_client()
    body = b"same-body"
    assert client._compute_hmac("1000", body) != client._compute_hmac("2000", body)


def test_hmac_known_vector():
    """Verify against a value computed independently."""
    key_bytes = bytes.fromhex(HMAC_HEX)
    body = b"hello"
    ts = "1000"
    body_hash = hashlib.sha256(body).hexdigest()
    sign_string = f"{ts}\n{body_hash}"
    expected = _hmac.new(key_bytes, sign_string.encode(), hashlib.sha256).hexdigest()
    assert make_client()._compute_hmac(ts, body) == expected


# ---------------------------------------------------------------------------
# 2. JWT generation
# ---------------------------------------------------------------------------


def test_jwt_required_claims():
    """JWT must carry iss, aud, sub, iat, exp with correct values."""
    client = make_client()
    before = int(time.time())
    encoded = client._build_jwt()
    after = int(time.time())

    payload = jwt.decode(
        encoded,
        SIGNING_VAL,
        algorithms=["HS256"],
        audience="fde-risk-api",
        issuer="fde",
    )
    assert payload["iss"] == "fde"
    assert payload["aud"] == "fde-risk-api"
    assert payload["sub"] == CLIENT_ID
    assert before <= payload["iat"] <= after
    assert payload["exp"] == payload["iat"] + 300


def test_jwt_custom_issuer_and_audience():
    """Custom issuer/audience flow through to the JWT."""
    client = FdeClient(
        endpoint=ENDPOINT,
        jwt_signing_key=SIGNING_VAL,
        hmac_key=HMAC_HEX,
        client_id="c1",
        jwt_issuer="my-issuer",
        jwt_audience="my-audience",
    )
    encoded = client._build_jwt()
    payload = jwt.decode(
        encoded,
        SIGNING_VAL,
        algorithms=["HS256"],
        audience="my-audience",
        issuer="my-issuer",
    )
    assert payload["iss"] == "my-issuer"
    assert payload["aud"] == "my-audience"


# ---------------------------------------------------------------------------
# 3. Request headers
# ---------------------------------------------------------------------------


def test_build_headers_has_all_keys():
    """_build_headers must return all four required headers."""
    client = make_client()
    body = b'{"test":1}'
    headers = client._build_headers(body)

    assert headers["Authorization"].startswith("Bearer ")
    assert "X-FDE-Timestamp" in headers
    assert "X-FDE-Signature" in headers
    assert headers["Content-Type"] == "application/json"


def test_headers_signature_matches_body():
    """X-FDE-Signature must be a valid HMAC of the body at the given timestamp."""
    client = make_client()
    body = b'{"a":1}'
    before = int(time.time())
    headers = client._build_headers(body)
    after = int(time.time())

    ts = headers["X-FDE-Timestamp"]
    assert before <= int(ts) <= after
    assert headers["X-FDE-Signature"] == _ref_hmac(HMAC_HEX, ts, body)


# ---------------------------------------------------------------------------
# 4. HTTP error handling
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_api_error_on_4xx():
    """Non-2xx response must raise FdeApiError with status_code and detail."""
    client = make_client()

    mock_response = MagicMock()
    mock_response.status_code = 401
    mock_response.json.return_value = {"detail": "unauthorized"}

    with patch.object(client, "_get_client") as mock_get:
        mock_http = MagicMock()
        mock_http.post = AsyncMock(return_value=mock_response)
        mock_get.return_value = mock_http

        with pytest.raises(FdeApiError) as exc_info:
            await client.evaluate_risk(
                EvaluateRequest(
                    eventCode="login",
                    sessionId="s1",
                    deviceToken="d1",
                    eventDetail=EventDetailModel(),
                )
            )

    err = exc_info.value
    assert err.status_code == 401
    assert "unauthorized" in str(err.detail)


@pytest.mark.asyncio
async def test_api_error_non_json_body():
    """FdeApiError.detail falls back to text when response is not JSON."""
    client = make_client()

    mock_response = MagicMock()
    mock_response.status_code = 500
    mock_response.json.side_effect = ValueError("not json")
    mock_response.text = "Internal Server Error"

    with patch.object(client, "_get_client") as mock_get:
        mock_http = MagicMock()
        mock_http.post = AsyncMock(return_value=mock_response)
        mock_get.return_value = mock_http

        with pytest.raises(FdeApiError) as exc_info:
            await client.submit_outcome(
                OutcomeRequest(
                    eventCode="login",
                    sessionId="s1",
                    deviceToken="d1",
                    eventDetail=EventDetailModel(),
                )
            )

    assert exc_info.value.detail == "Internal Server Error"


# ---------------------------------------------------------------------------
# 5. Full endpoint round-trips (mocked HTTP)
# ---------------------------------------------------------------------------


def _ok_post(response_body: dict) -> AsyncMock:
    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = response_body
    return AsyncMock(return_value=mock_response)


@pytest.mark.asyncio
async def test_evaluate_risk_happy_path():
    """evaluate_risk sends correct body and returns RiskEvaluateResponse."""
    client = make_client()
    fake_resp = {
        "request_id": "req-001",
        "risk_score": 42,
        "risk_level": "low",
        "decision": "accept",
    }

    request = EvaluateRequest(
        eventCode="login",
        sessionId="sess-abc",
        deviceToken="dev-xyz",
        userId="user-1",
        eventDetail=EventDetailModel(
            Login=LoginDetail(
                UserLoginName="alice",
                UserInfo=UserInfoModel(UserLevel="regular"),
                Cust=[CustKV(key="browser", value="chrome")],
            )
        ),
    )

    with patch.object(client, "_get_client") as mock_get:
        mock_http = MagicMock()
        mock_http.post = _ok_post(fake_resp)
        mock_get.return_value = mock_http

        result = await client.evaluate_risk(request)

    assert result["request_id"] == "req-001"
    assert result["risk_score"] == 42
    assert result["decision"] == "accept"

    sent_bytes = mock_http.post.call_args.kwargs["content"]
    sent = json.loads(sent_bytes)
    assert sent["eventCode"] == "login"
    assert sent["sessionId"] == "sess-abc"
    assert sent["eventDetail"]["Login"]["UserLoginName"] == "alice"
    assert sent["eventDetail"]["Login"]["Cust"][0] == {
        "key": "browser",
        "value": "chrome",
    }


@pytest.mark.asyncio
async def test_evaluate_risk_explain_param():
    """explain=True must add the query param."""
    client = make_client()
    fake_resp = {
        "request_id": "r2",
        "risk_score": 10,
        "risk_level": "low",
        "decision": "accept",
    }
    request = EvaluateRequest(
        eventCode="login",
        sessionId="s1",
        deviceToken="d1",
        eventDetail=EventDetailModel(),
    )

    with patch.object(client, "_get_client") as mock_get:
        mock_http = MagicMock()
        mock_http.post = _ok_post(fake_resp)
        mock_get.return_value = mock_http

        await client.evaluate_risk(request, explain=True)

    assert mock_http.post.call_args.kwargs["params"]["explain"] == "true"


@pytest.mark.asyncio
async def test_evaluate_risk_no_explain_param_by_default():
    """explain=False must not add the query param."""
    client = make_client()
    fake_resp = {
        "request_id": "r3",
        "risk_score": 5,
        "risk_level": "low",
        "decision": "accept",
    }
    request = EvaluateRequest(
        eventCode="login",
        sessionId="s1",
        deviceToken="d1",
        eventDetail=EventDetailModel(),
    )

    with patch.object(client, "_get_client") as mock_get:
        mock_http = MagicMock()
        mock_http.post = _ok_post(fake_resp)
        mock_get.return_value = mock_http

        await client.evaluate_risk(request)

    params = mock_http.post.call_args.kwargs.get("params", {})
    assert not params  # empty dict when explain is False


@pytest.mark.asyncio
async def test_submit_outcome_happy_path():
    """submit_outcome sends correct payload and returns OutcomeResponse."""
    client = make_client()
    fake_resp = {"request_id": "out-001"}

    request = OutcomeRequest(
        eventCode="login_result",
        sessionId="sess-abc",
        deviceToken="dev-xyz",
        eventDetail=EventDetailModel(
            LoginResult=LoginResultDetail(
                LoginResult=ResultStatus(Status="success"),
            )
        ),
    )

    with patch.object(client, "_get_client") as mock_get:
        mock_http = MagicMock()
        mock_http.post = _ok_post(fake_resp)
        mock_get.return_value = mock_http

        result = await client.submit_outcome(request)

    assert result["request_id"] == "out-001"

    sent = json.loads(mock_http.post.call_args.kwargs["content"])
    assert sent["eventCode"] == "login_result"
    assert sent["eventDetail"]["LoginResult"]["LoginResult"]["Status"] == "success"


@pytest.mark.asyncio
async def test_submit_labels_happy_path():
    """submit_labels sends decisionList and returns SubmitLabelsResponse."""
    client = make_client()
    fake_resp = {
        "request_id": "lbl-001",
        "created": 2,
        "not_found": 0,
        "errors": 0,
        "results": [
            {"request_id": "r1", "status": "created"},
            {"request_id": "r2", "status": "created"},
        ],
    }

    request = SubmitLabelsRequest(
        decisionList=[
            EnhancedDecisionItem(requestId="r1", result="reject"),
            EnhancedDecisionItem(requestId="r2", result="pass"),
        ]
    )

    with patch.object(client, "_get_client") as mock_get:
        mock_http = MagicMock()
        mock_http.post = _ok_post(fake_resp)
        mock_get.return_value = mock_http

        result = await client.submit_labels(request)

    assert result["created"] == 2
    assert result["errors"] == 0

    sent = json.loads(mock_http.post.call_args.kwargs["content"])
    assert len(sent["decisionList"]) == 2
    assert sent["decisionList"][0]["requestId"] == "r1"
    assert sent["decisionList"][0]["result"] == "reject"


# ---------------------------------------------------------------------------
# 6. HMAC byte-exactness: signed bytes == sent bytes
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_hmac_computed_over_exact_sent_bytes():
    """
    The HMAC signature in X-FDE-Signature must be computed over the exact
    bytes sent as the request body — not a re-serialised version.
    """
    client = make_client()
    fake_resp = {
        "request_id": "r-exact",
        "risk_score": 0,
        "risk_level": "low",
        "decision": "accept",
    }

    request = EvaluateRequest(
        eventCode="txn",
        sessionId="s1",
        deviceToken="d1",
        eventDetail=EventDetailModel(
            Transaction=TransactionDetail(Amount=100.0, Currency="USD")
        ),
    )

    with patch.object(client, "_get_client") as mock_get:
        mock_http = MagicMock()
        mock_http.post = _ok_post(fake_resp)
        mock_get.return_value = mock_http

        await client.evaluate_risk(request)

    call_kwargs = mock_http.post.call_args.kwargs
    sent_bytes: bytes = call_kwargs["content"]
    headers: dict = call_kwargs["headers"]
    ts = headers["X-FDE-Timestamp"]
    sig = headers["X-FDE-Signature"]

    # Recompute from the actual bytes that were sent
    expected_sig = _ref_hmac(HMAC_HEX, ts, sent_bytes)
    assert sig == expected_sig, (
        "Signature was not computed over the bytes that were sent"
    )


# ---------------------------------------------------------------------------
# 7. Type serialisation spot-checks
# ---------------------------------------------------------------------------


def test_evaluate_request_omits_none_fields():
    """Optional None fields must not appear in to_dict() output."""
    req = EvaluateRequest(
        eventCode="txn",
        sessionId="s",
        deviceToken="d",
        eventDetail=EventDetailModel(
            Transaction=TransactionDetail(Amount=99.5, Currency="USD")
        ),
    )
    d = req.to_dict()
    assert "userEmail" not in d
    assert "userPhone" not in d
    assert d["eventDetail"]["Transaction"]["Amount"] == 99.5


def test_submit_labels_serialisation():
    """SubmitLabelsRequest serialises decisionList with all set fields."""
    req = SubmitLabelsRequest(
        decisionList=[
            EnhancedDecisionItem(
                requestId="abc",
                result="fraud",
                labelSource="model",
                labelConfidence=0.95,
            )
        ]
    )
    d = req.to_dict()
    item = d["decisionList"][0]
    assert item["requestId"] == "abc"
    assert item["labelSource"] == "model"
    assert item["labelConfidence"] == 0.95


def test_transaction_detail_omits_none():
    """TransactionDetail.to_dict() must omit None fields."""
    td = TransactionDetail(Amount=50.0)
    d = td.to_dict()
    assert "Currency" not in d
    assert "MerchantId" not in d
    assert d["Amount"] == 50.0


# ---------------------------------------------------------------------------
# 8. Context manager lifecycle
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_context_manager_lifecycle():
    """Entering context creates http client; exiting closes it."""
    client = make_client()
    assert client._http is None

    async with client:
        assert client._http is not None

    assert client._http is None


@pytest.mark.asyncio
async def test_close_idempotent():
    """Calling close() after the context manager exits must not raise."""
    client = make_client()
    async with client:
        pass
    await client.close()  # second close — must be a no-op
