"""Tests for OllamaNativeClient — shared inference gateway Ollama implementation.

Covers:
- Successful generation with native schema format
- Prompt-only mode reporting when schema not supported
- Max tokens and context window configuration
- Stall detection triggers abort
- Error mapping (connection refused, timeout, model not found)
- Result captures correct metadata (model, duration, token counts)
- Stall detection is Ollama-specific (doesn't affect InferenceResult interface)

Requirements: 2.1, 2.6
"""
from __future__ import annotations

import json
from uuid import uuid4

import httpx
import pytest

from services.shared.inference.clients.ollama_native import (
    OllamaNativeClient,
    StallPolicy,
    _detect_loop,
    _map_http_status_to_category,
)
from services.shared.inference.errors import InferenceError, InferenceErrorCategory
from services.shared.inference.models import (
    ChatMessage,
    InferenceResult,
    InferenceTarget,
    ProviderCapabilities,
    StructuredGenerationRequest,
)


def _make_target(
    *,
    json_schema_capable: bool = True,
    context_window: int | None = None,
    max_output_tokens: int | None = None,
) -> InferenceTarget:
    """Create a test InferenceTarget for Ollama."""
    return InferenceTarget(
        endpoint_id=uuid4(),
        deployment_id=uuid4(),
        protocol="ollama_native",
        base_url="http://test-ollama:11434",
        model="test-model:7b",
        capabilities=ProviderCapabilities(
            chat_completions=True,
            json_schema=json_schema_capable,
        ),
        context_window=context_window,
        max_output_tokens=max_output_tokens,
    )


def _make_request(
    *,
    schema: dict | None = None,
    max_output_tokens: int = 2048,
    temperature: float = 0.0,
    seed: int | None = 0,
) -> StructuredGenerationRequest:
    """Create a test StructuredGenerationRequest."""
    return StructuredGenerationRequest(
        messages=[
            ChatMessage(role="system", content="You are a financial analyst."),
            ChatMessage(role="user", content="Analyze AAPL earnings."),
        ],
        json_schema=schema,
        max_output_tokens=max_output_tokens,
        temperature=temperature,
        seed=seed,
        timeout_seconds=30.0,
        trace_id="test-trace-001",
    )


def _streaming_response(
    content: str,
    *,
    model: str = "test-model:7b",
    prompt_eval_count: int = 150,
    eval_count: int = 200,
    total_duration_ns: int = 5_000_000_000,
) -> list[str]:
    """Build Ollama streaming response lines (newline-delimited JSON)."""
    lines = []
    # Stream content in chunks
    chunk_size = max(1, len(content) // 3)
    for i in range(0, len(content), chunk_size):
        chunk = content[i:i + chunk_size]
        lines.append(json.dumps({
            "model": model,
            "message": {"role": "assistant", "content": chunk},
            "done": False,
        }))

    # Final done message with metadata
    lines.append(json.dumps({
        "model": model,
        "message": {"role": "assistant", "content": ""},
        "done": True,
        "prompt_eval_count": prompt_eval_count,
        "eval_count": eval_count,
        "total_duration": total_duration_ns,
    }))
    return lines


def _make_streaming_transport(
    lines: list[str],
    *,
    status_code: int = 200,
) -> httpx.MockTransport:
    """Build a mock transport that returns streaming lines."""
    body = "\n".join(lines) + "\n"

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(
            status_code,
            content=body.encode(),
            headers={"content-type": "application/x-ndjson"},
        )

    return httpx.MockTransport(handler)


# --- Test: Successful generation with native schema format ---


@pytest.mark.asyncio
async def test_generate_success_with_schema_format():
    """Successful generation uses native format field when json_schema capable."""
    result_json = json.dumps({"signal": "bullish", "confidence": 0.85})
    lines = _streaming_response(result_json, eval_count=50, prompt_eval_count=100)
    transport = _make_streaming_transport(lines)
    http = httpx.AsyncClient(transport=transport)

    target = _make_target(json_schema_capable=True)
    client = OllamaNativeClient(target, http_client=http)

    schema = {"type": "object", "properties": {"signal": {"type": "string"}}}
    request = _make_request(schema=schema)

    result = await client.generate(request)

    assert isinstance(result, InferenceResult)
    assert result.structured_mode == "json_schema"
    assert result.parsed == {"signal": "bullish", "confidence": 0.85}
    assert result.content == result_json
    assert result.model == "test-model:7b"
    assert result.usage.output_tokens == 50
    assert result.usage.input_tokens == 100
    assert result.request_id == "test-trace-001"
    assert result.latency_ms >= 0
    assert result.endpoint_id == target.endpoint_id
    assert result.deployment_id == target.deployment_id
    assert result.protocol == "ollama_native"

    await client.close()


# --- Test: Prompt-only mode reporting ---


@pytest.mark.asyncio
async def test_generate_prompt_only_mode():
    """Reports prompt_only when schema requested but not natively supported."""
    result_json = json.dumps({"signal": "bearish"})
    lines = _streaming_response(result_json)
    transport = _make_streaming_transport(lines)
    http = httpx.AsyncClient(transport=transport)

    # No json_schema capability
    target = _make_target(json_schema_capable=False)
    client = OllamaNativeClient(target, http_client=http)

    schema = {"type": "object", "properties": {"signal": {"type": "string"}}}
    request = _make_request(schema=schema)

    result = await client.generate(request)

    assert result.structured_mode == "prompt_only"
    assert result.parsed == {"signal": "bearish"}

    await client.close()


@pytest.mark.asyncio
async def test_generate_no_schema_mode_none():
    """Reports 'none' when no schema is requested."""
    lines = _streaming_response("Free text response about markets.")
    transport = _make_streaming_transport(lines)
    http = httpx.AsyncClient(transport=transport)

    target = _make_target(json_schema_capable=True)
    client = OllamaNativeClient(target, http_client=http)

    request = _make_request(schema=None)

    result = await client.generate(request)

    assert result.structured_mode == "none"
    assert result.parsed is None
    assert "Free text response" in result.content

    await client.close()


# --- Test: Max tokens and context configuration ---


@pytest.mark.asyncio
async def test_max_tokens_and_context_in_payload():
    """num_predict and num_ctx are set in Ollama options from request/target."""
    captured_payload: dict = {}

    def handler(request: httpx.Request) -> httpx.Response:
        captured_payload.update(json.loads(request.content))
        body = "\n".join(_streaming_response("ok"))
        return httpx.Response(200, content=body.encode())

    transport = httpx.MockTransport(handler)
    http = httpx.AsyncClient(transport=transport)

    target = _make_target(context_window=32768)
    client = OllamaNativeClient(target, http_client=http)

    request = _make_request(max_output_tokens=4096, temperature=0.1, seed=42)

    await client.generate(request)

    assert captured_payload["options"]["num_predict"] == 4096
    assert captured_payload["options"]["num_ctx"] == 32768
    assert captured_payload["options"]["temperature"] == 0.1
    assert captured_payload["options"]["seed"] == 42
    assert captured_payload["model"] == "test-model:7b"
    assert captured_payload["stream"] is True

    await client.close()


@pytest.mark.asyncio
async def test_schema_passed_as_format_field():
    """When json_schema is capable, the schema is passed via format field."""
    captured_payload: dict = {}

    def handler(request: httpx.Request) -> httpx.Response:
        captured_payload.update(json.loads(request.content))
        body = "\n".join(_streaming_response('{"x": 1}'))
        return httpx.Response(200, content=body.encode())

    transport = httpx.MockTransport(handler)
    http = httpx.AsyncClient(transport=transport)

    target = _make_target(json_schema_capable=True)
    client = OllamaNativeClient(target, http_client=http)

    schema = {"type": "object", "properties": {"x": {"type": "integer"}}}
    request = _make_request(schema=schema)

    await client.generate(request)

    assert captured_payload["format"] == schema

    await client.close()


@pytest.mark.asyncio
async def test_no_format_field_when_not_capable():
    """When json_schema is not capable, format field is omitted."""
    captured_payload: dict = {}

    def handler(request: httpx.Request) -> httpx.Response:
        captured_payload.update(json.loads(request.content))
        body = "\n".join(_streaming_response('{"x": 1}'))
        return httpx.Response(200, content=body.encode())

    transport = httpx.MockTransport(handler)
    http = httpx.AsyncClient(transport=transport)

    target = _make_target(json_schema_capable=False)
    client = OllamaNativeClient(target, http_client=http)

    schema = {"type": "object", "properties": {"x": {"type": "integer"}}}
    request = _make_request(schema=schema)

    await client.generate(request)

    assert "format" not in captured_payload

    await client.close()


# --- Test: Stall detection triggers abort ---


@pytest.mark.asyncio
async def test_stall_detection_triggers_abort():
    """Stall detection raises InferenceError with STALL_DETECTED category."""
    # Generate highly repetitive content that triggers loop detection
    repeated = "buy buy buy " * 200  # Very repetitive

    # Build streaming lines that produce repetitive content slowly
    lines = []
    chunk_size = 50
    for i in range(0, len(repeated), chunk_size):
        chunk = repeated[i:i + chunk_size]
        lines.append(json.dumps({
            "model": "test-model:7b",
            "message": {"role": "assistant", "content": chunk},
            "done": False,
        }))
    # Add done at the end (but stall should abort before reaching it)
    lines.append(json.dumps({
        "model": "test-model:7b",
        "message": {"role": "assistant", "content": ""},
        "done": True,
        "eval_count": 500,
    }))

    transport = _make_streaming_transport(lines)
    http = httpx.AsyncClient(transport=transport)

    target = _make_target()
    # Aggressive stall policy for testing
    stall_policy = StallPolicy(
        enabled=True,
        check_interval_seconds=0.0,  # Check every iteration
        max_unchanged_intervals=2,
        loop_window=48,
        loop_threshold=0.5,
    )
    client = OllamaNativeClient(target, http_client=http, stall_policy=stall_policy)

    request = _make_request()

    with pytest.raises(InferenceError) as exc_info:
        await client.generate(request)

    assert exc_info.value.category == InferenceErrorCategory.STALL_DETECTED
    assert exc_info.value.retryable is True

    await client.close()


@pytest.mark.asyncio
async def test_stall_detection_disabled():
    """When stall detection is disabled, repetitive content completes normally."""
    repeated = "x" * 500
    lines = _streaming_response(repeated)
    transport = _make_streaming_transport(lines)
    http = httpx.AsyncClient(transport=transport)

    target = _make_target()
    stall_policy = StallPolicy(enabled=False)
    client = OllamaNativeClient(target, http_client=http, stall_policy=stall_policy)

    request = _make_request()

    result = await client.generate(request)
    assert result.content == repeated

    await client.close()


# --- Test: Error mapping ---


@pytest.mark.asyncio
async def test_error_connection_refused():
    """Connection refused maps to CONNECTION_REFUSED category."""

    def handler(request: httpx.Request) -> httpx.Response:
        raise httpx.ConnectError("Connection refused")

    transport = httpx.MockTransport(handler)
    http = httpx.AsyncClient(transport=transport)

    target = _make_target()
    client = OllamaNativeClient(target, http_client=http)
    request = _make_request()

    with pytest.raises(InferenceError) as exc_info:
        await client.generate(request)

    assert exc_info.value.category == InferenceErrorCategory.CONNECTION_REFUSED
    assert exc_info.value.retryable is True

    await client.close()


@pytest.mark.asyncio
async def test_error_timeout():
    """Timeout maps to TIMEOUT category."""

    def handler(request: httpx.Request) -> httpx.Response:
        raise httpx.ReadTimeout("timed out")

    transport = httpx.MockTransport(handler)
    http = httpx.AsyncClient(transport=transport)

    target = _make_target()
    client = OllamaNativeClient(target, http_client=http)
    request = _make_request()

    with pytest.raises(InferenceError) as exc_info:
        await client.generate(request)

    assert exc_info.value.category == InferenceErrorCategory.TIMEOUT
    assert exc_info.value.retryable is True

    await client.close()


@pytest.mark.asyncio
async def test_error_model_not_found():
    """HTTP 404 maps to MODEL_NOT_FOUND category."""
    error_body = json.dumps({"error": "model 'nonexistent' not found"})

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(404, content=error_body.encode())

    transport = httpx.MockTransport(handler)
    http = httpx.AsyncClient(transport=transport)

    target = _make_target()
    client = OllamaNativeClient(target, http_client=http)
    request = _make_request()

    with pytest.raises(InferenceError) as exc_info:
        await client.generate(request)

    assert exc_info.value.category == InferenceErrorCategory.MODEL_NOT_FOUND
    assert exc_info.value.status_code == 404
    assert exc_info.value.retryable is False

    await client.close()


@pytest.mark.asyncio
async def test_error_server_error():
    """HTTP 500 maps to SERVER_ERROR category."""
    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(500, content=b"Internal Server Error")

    transport = httpx.MockTransport(handler)
    http = httpx.AsyncClient(transport=transport)

    target = _make_target()
    client = OllamaNativeClient(target, http_client=http)
    request = _make_request()

    with pytest.raises(InferenceError) as exc_info:
        await client.generate(request)

    assert exc_info.value.category == InferenceErrorCategory.SERVER_ERROR
    assert exc_info.value.retryable is True

    await client.close()


@pytest.mark.asyncio
async def test_error_empty_response():
    """Empty content from Ollama raises EMPTY_RESPONSE."""
    # Only a done message with no content
    lines = [json.dumps({
        "model": "test-model:7b",
        "message": {"role": "assistant", "content": ""},
        "done": True,
        "eval_count": 0,
    })]
    transport = _make_streaming_transport(lines)
    http = httpx.AsyncClient(transport=transport)

    target = _make_target()
    client = OllamaNativeClient(target, http_client=http)
    request = _make_request()

    with pytest.raises(InferenceError) as exc_info:
        await client.generate(request)

    assert exc_info.value.category == InferenceErrorCategory.EMPTY_RESPONSE

    await client.close()


# --- Test: Result captures correct metadata ---


@pytest.mark.asyncio
async def test_result_metadata_captured():
    """InferenceResult captures model name, duration, and token counts from Ollama metadata."""
    content = json.dumps({"result": "test"})
    lines = _streaming_response(
        content,
        model="qwen3.5:9b",
        prompt_eval_count=250,
        eval_count=180,
        total_duration_ns=8_500_000_000,
    )
    transport = _make_streaming_transport(lines)
    http = httpx.AsyncClient(transport=transport)

    target = _make_target()
    client = OllamaNativeClient(target, http_client=http)
    request = _make_request()

    result = await client.generate(request)

    assert result.model == "qwen3.5:9b"
    assert result.usage.input_tokens == 250
    assert result.usage.output_tokens == 180
    assert result.latency_ms >= 0
    assert result.endpoint_id == target.endpoint_id
    assert result.deployment_id == target.deployment_id
    assert result.protocol == "ollama_native"

    await client.close()


# --- Test: Stall detection is Ollama-specific, doesn't affect interface ---


@pytest.mark.asyncio
async def test_stall_detection_ollama_specific_not_in_result():
    """Stall detection is Ollama-specific policy — successful results have no stall fields."""
    content = "Normal varied content with different words and ideas flowing naturally."
    lines = _streaming_response(content)
    transport = _make_streaming_transport(lines)
    http = httpx.AsyncClient(transport=transport)

    target = _make_target()
    stall_policy = StallPolicy(enabled=True)
    client = OllamaNativeClient(target, http_client=http, stall_policy=stall_policy)
    request = _make_request()

    result = await client.generate(request)

    # InferenceResult has no stall-related fields — it's protocol agnostic
    assert isinstance(result, InferenceResult)
    result_fields = set(InferenceResult.model_fields.keys())
    assert "stall_detected" not in result_fields
    assert "stall_policy" not in result_fields
    # The result is normal
    assert result.content == content

    await client.close()


# --- Test: Protocol validation ---


def test_wrong_protocol_raises():
    """OllamaNativeClient rejects non-ollama_native targets."""
    target = InferenceTarget(
        endpoint_id=uuid4(),
        deployment_id=uuid4(),
        protocol="openai_chat",
        base_url="http://test:8000",
        model="gpt-4",
        capabilities=ProviderCapabilities(),
    )

    with pytest.raises(ValueError, match="ollama_native"):
        OllamaNativeClient(target)


# --- Test: Helper functions ---


def test_detect_loop_with_repetition():
    """Loop detection catches repeated tail content."""
    content = "hello world " * 20
    assert _detect_loop(content, window=48, threshold=0.5) is True


def test_detect_loop_varied_content():
    """Loop detection does not trigger on varied content with default threshold."""
    content = (
        "Apple reported strong Q4 earnings with revenue up 12 percent. "
        "The company saw growth across all segments including services and Mac. "
        "CEO Tim Cook highlighted the success of Apple Intelligence features. "
        "Analysts raised their price targets following the announcement. "
        "Markets rallied today on strong earnings from tech sector leaders."
    )
    # Default threshold is 0.5 — normal English text has ~0.30 unique ratio
    # which is above 0.15 but below 0.5 — the key is tail not in body
    assert _detect_loop(content, window=64, threshold=0.15) is False


def test_detect_loop_short_content():
    """Loop detection returns False for content shorter than 2x window."""
    content = "short"
    assert _detect_loop(content, window=64, threshold=0.5) is False


def test_map_http_status_categories():
    """HTTP status codes map to correct error categories."""
    assert _map_http_status_to_category(401) == InferenceErrorCategory.AUTH_FAILED
    assert _map_http_status_to_category(403) == InferenceErrorCategory.FORBIDDEN
    assert _map_http_status_to_category(404) == InferenceErrorCategory.MODEL_NOT_FOUND
    assert _map_http_status_to_category(429) == InferenceErrorCategory.RATE_LIMITED
    assert _map_http_status_to_category(400) == InferenceErrorCategory.BAD_REQUEST
    assert _map_http_status_to_category(500) == InferenceErrorCategory.SERVER_ERROR
    assert _map_http_status_to_category(503) == InferenceErrorCategory.SERVER_ERROR
    assert _map_http_status_to_category(418) == InferenceErrorCategory.UNKNOWN
