"""
BIBIVIDI — Proxy tests
Run with: pytest tests/
"""
import json
from unittest.mock import AsyncMock, patch

import pytest
from httpx import ASGITransport, AsyncClient

from bvd.core.proxy import _estimate_tokens, _extract_prompt, _inject_result, app
from bvd.engine.library import Script
from bvd.engine.matcher import MatchResult
from bvd.engine.runner import RunResult

# ── Unit tests ────────────────────────────────────────────────────────────────

def test_extract_prompt_openai_format():
    body = {
        "model": "gpt-4",
        "messages": [
            {"role": "system", "content": "You are helpful."},
            {"role": "user", "content": "Summarise this contract."},
        ],
    }
    assert _extract_prompt(body) == "Summarise this contract."


def test_extract_prompt_anthropic_format():
    body = {
        "model": "claude-3-5-sonnet-20241022",
        "messages": [
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "Hello from Anthropic format."}
                ],
            }
        ],
    }
    assert _extract_prompt(body) == "Hello from Anthropic format."


def test_extract_prompt_no_messages():
    body = {"prompt": "\n\nHuman: What is 2+2?\n\nAssistant:"}
    assert "2+2" in _extract_prompt(body)


def test_inject_result():
    body = {
        "messages": [
            {"role": "user", "content": "Long document..."},
        ]
    }
    result = _inject_result(body, "Condensed: 3 clauses found.")
    assert "Condensed" in result["messages"][0]["content"]
    assert "BIBIVIDI" in result["messages"][0]["content"]


def test_estimate_tokens():
    text = "a" * 400
    # tiktoken is available — result should be a positive integer
    count = _estimate_tokens(text)
    assert isinstance(count, int)
    assert count >= 1


# ── Integration tests ─────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_health_endpoint():
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as client:
        resp = await client.get("/bibividi/health")
    assert resp.status_code == 200
    data = resp.json()
    assert data["status"] == "ok"
    assert "proxy" in data


@pytest.mark.asyncio
async def test_ssrf_blocked_for_non_allowlisted_host():
    """Requests to hosts not in BIBIVIDI_TARGET_APIS must return 403."""
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as client:
        resp = await client.post(
            "/v1/messages",
            headers={"Host": "169.254.169.254"},
            json={"messages": [{"role": "user", "content": "hi"}]},
        )
    assert resp.status_code == 403
    assert b"allowlist" in resp.content


@pytest.mark.asyncio
async def test_ssrf_blocked_for_internal_service():
    """Crafted Host pointing to a local service is rejected."""
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as client:
        resp = await client.post(
            "/v1/messages",
            headers={"Host": "localhost:5432"},
            json={"messages": [{"role": "user", "content": "hi"}]},
        )
    assert resp.status_code == 403


# ── Dry-run mode ──────────────────────────────────────────────────────────────

_DRY_SCRIPT = Script(
    id="dry-run-script",
    name="Dry Run Script",
    description="Used in dry-run tests",
    code='print("[CONDENSED]")',
)
_DRY_MATCH = MatchResult(script=_DRY_SCRIPT, script_id=_DRY_SCRIPT.id, score=0.92)
_DRY_RUN = RunResult(
    output="[CONDENSED OUTPUT]",
    stdout="[CONDENSED OUTPUT]",
    stderr="",
    exit_code=0,
    timed_out=False,
)


@pytest.mark.asyncio
async def test_dry_run_does_not_inject_condensed_content(tmp_path):
    """In dry-run mode the original request body reaches the upstream unchanged."""
    captured_body: dict = {}

    async def fake_request(method, url, **kwargs):
        captured_body.update(json.loads(kwargs.get("content", b"{}")))
        import httpx
        return httpx.Response(
            200,
            json={
                "id": "msg_dry",
                "type": "message",
                "role": "assistant",
                "model": "claude-haiku-4-5-20251001",
                "content": [{"type": "text", "text": "dry"}],
                "stop_reason": "end_turn",
                "usage": {"input_tokens": 10, "output_tokens": 2},
            },
        )

    mock_client = AsyncMock()
    mock_client.request = fake_request

    original_prompt = "Analyse ce contrat et résume les clauses importantes."

    with (
        patch("bvd.config.settings.target_apis", ["api.anthropic.com"]),
        patch("bvd.config.settings.db_path", tmp_path / "dry.db"),
        patch("bvd.config.settings.track_savings", False),
        patch("bvd.config.settings.dry_run", True),
        patch("bvd.core.proxy.get_client", AsyncMock(return_value=mock_client)),
        patch("bvd.core.proxy.matcher.match", AsyncMock(return_value=_DRY_MATCH)),
        patch("bvd.core.proxy.run_script", AsyncMock(return_value=_DRY_RUN)),
    ):
        async with AsyncClient(
            transport=ASGITransport(app=app), base_url="http://test"
        ) as client:
            resp = await client.post(
                "/v1/messages",
                headers={"Host": "api.anthropic.com"},
                content=json.dumps({
                    "model": "claude-haiku-4-5-20251001",
                    "max_tokens": 50,
                    "messages": [{"role": "user", "content": original_prompt}],
                }).encode(),
            )

    assert resp.status_code == 200
    # Original prompt must be forwarded unchanged — dry-run must NOT inject
    user_msg = captured_body["messages"][-1]["content"]
    assert original_prompt in user_msg
    assert "[CONDENSED OUTPUT]" not in user_msg
    assert "[BIBIVIDI: pre-processed]" not in user_msg


# ── Negative-saving guard ──────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_negative_saving_skill_not_injected(tmp_path):
    """If the skill output is larger than the original prompt, do NOT inject it."""
    captured_body: dict = {}

    async def fake_request(method, url, **kwargs):
        captured_body.update(json.loads(kwargs.get("content", b"{}")))
        import httpx
        return httpx.Response(
            200,
            json={
                "id": "msg_neg",
                "type": "message",
                "role": "assistant",
                "model": "claude-haiku-4-5-20251001",
                "content": [{"type": "text", "text": "ok"}],
                "stop_reason": "end_turn",
                "usage": {"input_tokens": 5, "output_tokens": 1},
            },
        )

    mock_client = AsyncMock()
    mock_client.request = fake_request

    # A short prompt so the bloated skill output will have more tokens
    short_prompt = "Translate: hello"
    # Skill output that is clearly larger than the 4-token prompt
    bloated_output = "=== BIBIVIDI HEADER ===" + (" extra words" * 50)

    bloated_run = RunResult(
        output=bloated_output,
        stdout=bloated_output,
        stderr="",
        exit_code=0,
        timed_out=False,
    )
    bloated_script = Script(
        id="bloated-skill",
        name="Bloated Skill",
        description="Returns more tokens than it receives",
        code='print("big output")',
    )
    bloated_match = MatchResult(script=bloated_script, script_id=bloated_script.id, score=0.91)

    with (
        patch("bvd.config.settings.target_apis", ["api.anthropic.com"]),
        patch("bvd.config.settings.db_path", tmp_path / "neg.db"),
        patch("bvd.config.settings.track_savings", False),
        patch("bvd.config.settings.dry_run", False),
        patch("bvd.core.proxy.get_client", AsyncMock(return_value=mock_client)),
        patch("bvd.core.proxy.matcher.match", AsyncMock(return_value=bloated_match)),
        patch("bvd.core.proxy.run_script", AsyncMock(return_value=bloated_run)),
    ):
        async with AsyncClient(
            transport=ASGITransport(app=app), base_url="http://test"
        ) as client:
            resp = await client.post(
                "/v1/messages",
                headers={"Host": "api.anthropic.com"},
                content=json.dumps({
                    "model": "claude-haiku-4-5-20251001",
                    "max_tokens": 50,
                    "messages": [{"role": "user", "content": short_prompt}],
                }).encode(),
            )

    assert resp.status_code == 200
    # Skill had negative saving — original prompt must be forwarded unchanged
    user_msg = captured_body["messages"][-1]["content"]
    assert short_prompt in user_msg
    assert "BIBIVIDI: pre-processed" not in user_msg
    assert bloated_output not in user_msg


# ── Pending skill passthrough ─────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_proxy_pending_skill_passes_through_cleanly(tmp_path):
    """When the matched skill is pending approval:
    - The original request body is forwarded unchanged (no injection)
    - Stats are NOT recorded (no noise attributed to the pending skill)
    - maybe_generate IS NOT called (a pending skill already exists for this prompt)
    """
    captured_body: dict = {}

    async def fake_request(method, url, **kwargs):
        captured_body.update(json.loads(kwargs.get("content", b"{}")))
        import httpx
        return httpx.Response(
            200,
            json={
                "id": "msg_pending",
                "type": "message",
                "role": "assistant",
                "model": "claude-haiku-4-5-20251001",
                "content": [{"type": "text", "text": "ok"}],
                "stop_reason": "end_turn",
                "usage": {"input_tokens": 10, "output_tokens": 2},
            },
        )

    mock_client = AsyncMock()
    mock_client.request = fake_request

    pending_script = Script(
        id="pending-skill",
        name="Pending Skill",
        description="Not yet approved",
        code='print("should not run")',
        status="pending",
    )
    pending_match = MatchResult(script=pending_script, script_id=pending_script.id, score=0.91)
    original_prompt = "Analyse this invoice and extract line items."

    recorded_calls: list = []

    async def fake_record(**kwargs):
        recorded_calls.append(kwargs)
        return None

    with (
        patch("bvd.config.settings.target_apis", ["api.anthropic.com"]),
        patch("bvd.config.settings.db_path", tmp_path / "pending.db"),
        patch("bvd.config.settings.track_savings", True),
        patch("bvd.config.settings.dry_run", False),
        patch("bvd.core.proxy.get_client", AsyncMock(return_value=mock_client)),
        patch("bvd.core.proxy.matcher.match", AsyncMock(return_value=pending_match)),
        patch("bvd.core.proxy.stats_tracker.record", fake_record),
        patch("bvd.core.proxy.generator.maybe_generate", AsyncMock()) as mock_generate,
    ):
        async with AsyncClient(
            transport=ASGITransport(app=app), base_url="http://test"
        ) as client:
            resp = await client.post(
                "/v1/messages",
                headers={"Host": "api.anthropic.com"},
                content=json.dumps({
                    "model": "claude-haiku-4-5-20251001",
                    "max_tokens": 50,
                    "messages": [{"role": "user", "content": original_prompt}],
                }).encode(),
            )

    assert resp.status_code == 200
    # Original prompt must be forwarded unchanged
    user_msg = captured_body["messages"][-1]["content"]
    assert original_prompt in user_msg
    assert "BIBIVIDI: pre-processed" not in user_msg
    # Stats must NOT be recorded with the pending skill's ID
    assert all(r.get("script_id") != "pending-skill" for r in recorded_calls)
    # Generator must NOT be called (pending skill already covers this prompt)
    mock_generate.assert_not_called()


# ── TODO-24: Gemini + free-tier provider support ──────────────────────────────

def test_extract_prompt_gemini_happy_path():
    """Gemini multi-turn contents[].parts[].text is extracted correctly."""
    body = {
        "contents": [
            {"role": "user", "parts": [{"text": "Summarise this invoice."}]},
            {"role": "model", "parts": [{"text": "Sure, I will summarise it."}]},
            {"role": "user", "parts": [{"text": "Focus on totals only."}]},
        ]
    }
    assert _extract_prompt(body) == "Focus on totals only."


def test_extract_prompt_gemini_single_turn_no_role():
    """Gemini single-turn request (no role field) is extracted correctly."""
    body = {
        "contents": [
            {"parts": [{"text": "Hello from Gemini single-turn."}]}
        ]
    }
    assert _extract_prompt(body) == "Hello from Gemini single-turn."


def test_extract_prompt_gemini_malformed_returns_empty():
    """Gemini body with empty parts returns empty string, not an error."""
    body = {
        "contents": [
            {"role": "user", "parts": []}
        ]
    }
    assert _extract_prompt(body) == ""


def test_inject_result_gemini_reconstructs_contents_format():
    """_inject_result replaces the last user parts with the condensed text."""
    body = {
        "contents": [
            {"role": "user", "parts": [{"text": "Long invoice text..."}]},
        ]
    }
    result = _inject_result(body, "Total: €1,200")
    parts = result["contents"][0]["parts"]
    assert len(parts) == 1
    assert "Total: €1,200" in parts[0]["text"]
    assert "BIBIVIDI" in parts[0]["text"]
    # messages key must not be introduced
    assert "messages" not in result


def test_inject_result_gemini_multi_turn_targets_last_user():
    """_inject_result targets the last user turn in a multi-turn Gemini body."""
    body = {
        "contents": [
            {"role": "user", "parts": [{"text": "First user message."}]},
            {"role": "model", "parts": [{"text": "Model reply."}]},
            {"role": "user", "parts": [{"text": "Second user message."}]},
        ]
    }
    result = _inject_result(body, "condensed")
    assert result["contents"][0]["parts"][0]["text"] == "First user message."
    assert "condensed" in result["contents"][2]["parts"][0]["text"]


@pytest.mark.asyncio
async def test_ssrf_guard_blocks_localhost_by_default():
    """localhost is rejected by default (allow_local_apis=False)."""
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as client:
        resp = await client.post(
            "/api/generate",
            headers={"Host": "localhost:11434"},
            json={"model": "llama3", "prompt": "hello"},
        )
    assert resp.status_code == 403
    assert b"allowlist" in resp.content


@pytest.mark.asyncio
async def test_ssrf_guard_allows_localhost_with_flag(tmp_path):
    """When allow_local_apis=True, localhost is forwarded instead of blocked."""
    import httpx

    async def fake_request(method, url, **kwargs):
        return httpx.Response(200, json={"response": "ok"})

    mock_client = AsyncMock()
    mock_client.request = fake_request

    with (
        patch("bvd.config.settings.allow_local_apis", True),
        patch("bvd.config.settings.target_apis", []),
        patch("bvd.config.settings.db_path", tmp_path / "local.db"),
        patch("bvd.config.settings.track_savings", False),
        patch("bvd.core.proxy.get_client", AsyncMock(return_value=mock_client)),
        patch("bvd.core.proxy.matcher.match", AsyncMock(return_value=None)),
        patch("bvd.config.settings.generator_enabled", False),
        patch("bvd.config.settings.enable_auto_assembly", False),
    ):
        async with AsyncClient(
            transport=ASGITransport(app=app), base_url="http://test"
        ) as client:
            resp = await client.post(
                "/api/generate",
                headers={"Host": "localhost:11434"},
                json={"model": "llama3", "prompt": "hello"},
            )
    assert resp.status_code == 200
