# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import json
from unittest import mock
from unittest.mock import AsyncMock

from google.adk.models.apigee_llm import ChatCompletionsResponseHandler
from google.adk.models.apigee_llm import CompletionsHTTPClient
from google.adk.models.llm_request import LlmRequest
from google.genai import types
import httpx
import pytest


@pytest.fixture
def client():
  return CompletionsHTTPClient(base_url='https://localhost')


@pytest.fixture(name='llm_request')
def fixture_llm_request():
  return LlmRequest(
      model='apigee/open_llama',
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='Hello')])
      ],
  )


@pytest.mark.asyncio
async def test_construct_payload_basic_payload(client, llm_request):
  mock_response = AsyncMock(spec=httpx.Response)
  mock_response.json.return_value = {
      'choices': [{'message': {'role': 'assistant', 'content': 'Hi'}}]
  }
  mock_response.status_code = 200

  with mock.patch.object(
      httpx.AsyncClient, 'post', return_value=mock_response
  ) as mock_post:
    _ = [
        r
        async for r in client.generate_content_async(llm_request, stream=False)
    ]

    mock_post.assert_called_once()
    call_args = mock_post.call_args
    url = call_args[0][0]
    kwargs = call_args[1]

    assert url == 'https://localhost/chat/completions'
    payload = kwargs['json']
    assert payload['model'] == 'open_llama'
    assert payload['stream'] is False
    assert len(payload['messages']) == 1
    assert payload['messages'][0]['role'] == 'user'
    assert payload['messages'][0]['content'] == 'Hello'


@pytest.mark.asyncio
async def test_construct_payload_with_config(client, llm_request):
  llm_request.config = types.GenerateContentConfig(
      temperature=0.7,
      top_p=0.9,
      max_output_tokens=100,
      stop_sequences=['STOP'],
      frequency_penalty=0.5,
      presence_penalty=0.5,
      seed=42,
      candidate_count=2,
      response_mime_type='application/json',
  )

  mock_response = AsyncMock(spec=httpx.Response)
  mock_response.json.return_value = {
      'choices': [{'message': {'role': 'assistant', 'content': 'Hi'}}]
  }
  mock_response.status_code = 200

  with mock.patch.object(
      httpx.AsyncClient, 'post', return_value=mock_response
  ) as mock_post:
    _ = [
        r
        async for r in client.generate_content_async(llm_request, stream=False)
    ]

    mock_post.assert_called_once()
    payload = mock_post.call_args[1]['json']

    assert payload['temperature'] == 0.7
    assert payload['top_p'] == 0.9
    assert payload['max_tokens'] == 100
    assert payload['stop'] == ['STOP']
    assert payload['frequency_penalty'] == 0.5
    assert payload['presence_penalty'] == 0.5
    assert payload['seed'] == 42
    assert payload['n'] == 2
    assert payload['response_format'] == {'type': 'json_object'}


@pytest.mark.asyncio
async def test_construct_payload_with_tools(client, llm_request):
  tool = types.Tool(
      function_declarations=[
          types.FunctionDeclaration(
              name='get_weather',
              description='Get weather',
              parameters=types.Schema(
                  type=types.Type.OBJECT,
                  properties={'location': types.Schema(type=types.Type.STRING)},
              ),
          )
      ]
  )
  llm_request.config = types.GenerateContentConfig(tools=[tool])

  mock_response = AsyncMock(spec=httpx.Response)
  mock_response.json.return_value = {
      'choices': [{'message': {'role': 'assistant', 'content': 'Hi'}}]
  }
  mock_response.status_code = 200

  with mock.patch.object(
      httpx.AsyncClient, 'post', return_value=mock_response
  ) as mock_post:
    _ = [
        r
        async for r in client.generate_content_async(llm_request, stream=False)
    ]

    mock_post.assert_called_once()
    payload = mock_post.call_args[1]['json']
    assert 'tools' in payload
    assert payload['tools'][0]['function']['name'] == 'get_weather'


@pytest.mark.asyncio
async def test_construct_payload_system_instruction(client, llm_request):
  llm_request.config = types.GenerateContentConfig(
      system_instruction='You are a helpful assistant.'
  )
  mock_response = AsyncMock(spec=httpx.Response)
  mock_response.json.return_value = {
      'choices': [{'message': {'role': 'assistant', 'content': 'Hi'}}]
  }
  mock_response.status_code = 200

  with mock.patch.object(
      httpx.AsyncClient, 'post', return_value=mock_response
  ) as mock_post:
    _ = [
        r
        async for r in client.generate_content_async(llm_request, stream=False)
    ]

    payload = mock_post.call_args[1]['json']
    assert payload['messages'][0]['role'] == 'system'
    assert payload['messages'][0]['content'] == 'You are a helpful assistant.'
    # Ensure user message follows system
    assert payload['messages'][1]['role'] == 'user'


@pytest.mark.asyncio
async def test_construct_payload_multimodal_content(client):
  # Mock inline_data for image
  image_data = b'fake_image_bytes'
  llm_request = LlmRequest(
      model='apigee/open_llama',
      contents=[
          types.Content(
              role='user',
              parts=[
                  types.Part.from_text(text='What is this?'),
                  types.Part.from_bytes(
                      data=image_data, mime_type='image/jpeg'
                  ),
              ],
          )
      ],
  )

  mock_response = AsyncMock(spec=httpx.Response)
  mock_response.json.return_value = {
      'choices': [
          {'message': {'role': 'assistant', 'content': 'It is an image'}}
      ]
  }

  mock_response.status_code = 200

  with mock.patch.object(
      httpx.AsyncClient, 'post', return_value=mock_response
  ) as mock_post:
    _ = [
        r
        async for r in client.generate_content_async(llm_request, stream=False)
    ]

    mock_post.assert_called_once()
    payload = mock_post.call_args[1]['json']
    assert len(payload['messages']) == 1
    message = payload['messages'][0]
    assert message['role'] == 'user'
    assert isinstance(message['content'], list)
    assert len(message['content']) == 2
    assert message['content'][0] == {'type': 'text', 'text': 'What is this?'}
    assert message['content'][1]['type'] == 'image_url'
    # Base64 encoding of b'fake_image_bytes' is 'ZmFrZV9pbWFnZV9ieXRlcw=='
    assert message['content'][1]['image_url']['url'] == (
        'data:image/jpeg;base64,ZmFrZV9pbWFnZV9ieXRlcw=='
    )


@pytest.mark.asyncio
async def test_construct_payload_image_file_uri(client):
  llm_request = LlmRequest(
      model='apigee/open_llama',
      contents=[
          types.Content(
              role='user',
              parts=[
                  types.Part.from_uri(
                      file_uri='https://localhost/image.jpg',
                      mime_type='image/jpeg',
                  )
              ],
          )
      ],
  )

  mock_response = AsyncMock(spec=httpx.Response)
  mock_response.json.return_value = {
      'choices': [
          {'message': {'role': 'assistant', 'content': 'It is an image'}}
      ]
  }
  mock_response.status_code = 200

  with mock.patch.object(
      httpx.AsyncClient, 'post', return_value=mock_response
  ) as mock_post:
    _ = [
        r
        async for r in client.generate_content_async(llm_request, stream=False)
    ]

    mock_post.assert_called_once()
    payload = mock_post.call_args[1]['json']
    assert len(payload['messages']) == 1
    message = payload['messages'][0]
    assert message['role'] == 'user'
    assert isinstance(message['content'], list)
    assert message['content'][0] == {
        'type': 'image_url',
        'image_url': {'url': 'https://localhost/image.jpg'},
    }


@pytest.mark.asyncio
async def test_generate_content_async_function_call_response(
    client, llm_request
):
  # Mock response with tool call
  mock_response = AsyncMock(spec=httpx.Response)
  mock_response.json.return_value = {
      'choices': [{
          'message': {
              'role': 'assistant',
              'content': None,
              'tool_calls': [{
                  'id': 'call_123',
                  'type': 'function',
                  'function': {
                      'name': 'get_weather',
                      'arguments': '{"location": "London"}',
                  },
              }],
          }
      }]
  }
  mock_response.status_code = 200

  with mock.patch.object(httpx.AsyncClient, 'post', return_value=mock_response):
    responses = [
        r
        async for r in client.generate_content_async(llm_request, stream=False)
    ]

    assert len(responses) == 1
    part = responses[0].content.parts[0]
    assert part.function_call
    assert part.function_call.name == 'get_weather'
    assert part.function_call.args == {'location': 'London'}
    assert part.function_call.id == 'call_123'


@pytest.mark.asyncio
@pytest.mark.parametrize(
    ('response_json_schema', 'response_mime_type', 'expected_response_format'),
    [
        # Case 1: Only response_json_schema is provided
        (
            {'type': 'object', 'properties': {'name': {'type': 'string'}}},
            None,
            {
                'type': 'json_schema',
                'json_schema': {
                    'type': 'object',
                    'properties': {'name': {'type': 'string'}},
                },
            },
        ),
        # Case 2: Both provided, schema takes precedence
        (
            {'type': 'object', 'properties': {'name': {'type': 'string'}}},
            'application/json',
            {
                'type': 'json_schema',
                'json_schema': {
                    'type': 'object',
                    'properties': {'name': {'type': 'string'}},
                },
            },
        ),
        # Case 3: Only response_mime_type is provided
        (
            None,
            'application/json',
            {'type': 'json_object'},
        ),
    ],
)
async def test_construct_payload_response_format(
    client,
    llm_request,
    response_json_schema,
    response_mime_type,
    expected_response_format,
):
  llm_request.config = types.GenerateContentConfig(
      response_json_schema=response_json_schema,
      response_mime_type=response_mime_type,
  )
  mock_response = AsyncMock(spec=httpx.Response)
  mock_response.json.return_value = {
      'choices': [{'message': {'role': 'assistant', 'content': '{}'}}]
  }
  mock_response.status_code = 200

  with mock.patch.object(
      httpx.AsyncClient, 'post', return_value=mock_response
  ) as mock_post:
    _ = [
        r
        async for r in client.generate_content_async(llm_request, stream=False)
    ]

    mock_post.assert_called_once()
    payload = mock_post.call_args[1]['json']

    assert payload['response_format'] == expected_response_format


@pytest.mark.asyncio
async def test_generate_content_async_invalid_tool_call_type_raises_error(
    client, llm_request
):
  # Mock response with invalid tool call type
  mock_response = AsyncMock(spec=httpx.Response)
  mock_response.json.return_value = {
      'choices': [{
          'message': {
              'role': 'assistant',
              'content': None,
              'tool_calls': [{
                  'id': 'call_123',
                  # Invalid type
                  'type': 'custom',
                  'custom': {
                      'name': 'read_string',
                      'input': 'Hi! The this is a custom tool call!',
                  },
              }],
          }
      }]
  }
  mock_response.status_code = 200

  with mock.patch.object(httpx.AsyncClient, 'post', return_value=mock_response):
    with pytest.raises(ValueError, match='Unsupported tool_call type: custom'):
      _ = [
          r
          async for r in client.generate_content_async(
              llm_request, stream=False
          )
      ]


@pytest.mark.asyncio
async def test_generate_content_async_function_call_response(
    client, llm_request
):
  # Mock response with deprecated function call
  mock_response = AsyncMock(spec=httpx.Response)
  mock_response.json.return_value = {
      'choices': [{
          'message': {
              'role': 'assistant',
              'content': None,
              'function_call': {
                  'name': 'get_weather',
                  'arguments': '{"location": "London"}',
              },
          }
      }]
  }
  mock_response.status_code = 200

  with mock.patch.object(httpx.AsyncClient, 'post', return_value=mock_response):
    responses = [
        r
        async for r in client.generate_content_async(llm_request, stream=False)
    ]

    assert len(responses) == 1
    part = responses[0].content.parts[0]
    assert part.function_call
    assert part.function_call.name == 'get_weather'
    assert part.function_call.args == {'location': 'London'}
    assert part.function_call.id is None


@pytest.mark.asyncio
async def test_generate_content_async_streaming_function_call():
  local_client = CompletionsHTTPClient(base_url='https://localhost')
  llm_request = LlmRequest(
      model='apigee/test',
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='hi')])
      ],
  )

  # Mock chunks simulating split arguments
  chunk_data_0 = {
      'id': 'chatcmpl-123',
      'object': 'chat.completion.chunk',
      'created': 1234567890,
      'model': 'gpt-3.5-turbo',
      'service_tier': 'default',
      'choices': [{
          'index': 0,
          'delta': {
              'tool_calls': [{
                  'index': 0,
                  'id': 'call_123',
                  'type': 'function',
                  'function': {'name': 'get_weather', 'arguments': ''},
              }]
          },
          'finish_reason': None,
      }],
  }
  chunk_data_1 = {
      'id': 'chatcmpl-123',
      'object': 'chat.completion.chunk',
      'created': 1234567890,
      'model': 'gpt-3.5-turbo',
      'service_tier': 'default',
      'choices': [{
          'index': 0,
          'delta': {
              'tool_calls': [{
                  'index': 0,
                  'function': {'arguments': '{"location": "London"}'},
              }]
          },
          'finish_reason': None,
      }],
  }
  chunk_data_2 = {
      'id': 'chatcmpl-123',
      'object': 'chat.completion.chunk',
      'created': 1234567890,
      'model': 'gpt-3.5-turbo',
      'service_tier': 'default',
      'choices': [{
          'index': 0,
          'delta': {
              'tool_calls': [{
                  'index': 0,
                  'function': {'arguments': '{"country": "UK"}'},
              }]
          },
          'finish_reason': None,
      }],
  }
  chunk_data_3 = {
      'id': 'chatcmpl-123',
      'object': 'chat.completion.chunk',
      'created': 1234567890,
      'model': 'gpt-3.5-turbo',
      'service_tier': 'default',
      'choices': [{'index': 0, 'delta': {}, 'finish_reason': 'tool_calls'}],
      'usage': {
          'prompt_tokens': 10,
          'completion_tokens': 20,
          'total_tokens': 30,
      },
  }

  chunks = [
      f'{json.dumps(chunk_data_0)}\n',
      f'{json.dumps(chunk_data_1)}\n',
      f'{json.dumps(chunk_data_2)}\n',
      f'{json.dumps(chunk_data_3)}\n',
  ]

  async def mock_aiter_lines():
    for chunk in chunks:
      yield chunk

  mock_response = AsyncMock(spec=httpx.Response)
  mock_response.aiter_lines.return_value = mock_aiter_lines()
  mock_response.status_code = 200

  mock_stream_ctx = mock.AsyncMock()
  mock_stream_ctx.__aenter__.return_value = mock_response

  with mock.patch.object(
      httpx.AsyncClient, 'stream', return_value=mock_stream_ctx
  ):
    responses = [
        r
        async for r in local_client.generate_content_async(
            llm_request, stream=True
        )
    ]
    # Check that we get 5 responses (one per chunk + extra final accumulated)
    assert len(responses) == 5

    # Check 1st response: partial tool call, empty args
    assert responses[0].partial is True
    assert responses[0].content.parts[0].function_call.name == 'get_weather'
    assert responses[0].content.parts[0].function_call.id == 'call_123'

    # Check 2nd response: full args for first update
    assert responses[1].partial is True
    assert responses[1].content.parts[0].function_call.args == {
        'location': 'London'
    }

    # Check 3rd response: full args for second update (merged)
    assert responses[2].partial is True
    assert responses[2].content.parts[0].function_call.args == {'country': 'UK'}

    # Check 4th response: last delta (empty)
    assert responses[3].partial is True
    assert responses[3].content.parts == []

    # Check 5th response: final accumulated
    assert responses[4].finish_reason == types.FinishReason.STOP
    # Full accumulated args
    assert responses[4].content.parts[0].function_call.args == {
        'location': 'London',
        'country': 'UK',
    }

    # Check metadata and usage
    assert responses[4].model_version == 'gpt-3.5-turbo'
    assert responses[4].custom_metadata['id'] == 'chatcmpl-123'
    assert responses[4].custom_metadata['created'], 1234567890
    assert responses[4].custom_metadata['object'], 'chat.completion.chunk'
    assert responses[4].custom_metadata['service_tier'], 'default'
    assert responses[4].usage_metadata is not None
    assert responses[4].usage_metadata.prompt_token_count == 10
    assert responses[4].usage_metadata.candidates_token_count == 20
    assert responses[4].usage_metadata.total_token_count == 30


@pytest.mark.asyncio
async def test_generate_content_async_streaming_multiple_function_calls():
  # Mock streaming response with multiple tool calls
  local_client = CompletionsHTTPClient(base_url='https://localhost')
  llm_request = LlmRequest(
      model='apigee/test',
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='hi')])
      ],
  )
  chunk_data_1 = {
      'choices': [{
          'index': 0,
          'delta': {
              'tool_calls': [
                  {
                      'index': 0,
                      'id': 'call_1',
                      'type': 'function',
                      'function': {'name': 'func_1', 'arguments': ''},
                  },
                  {
                      'index': 1,
                      'id': 'call_2',
                      'type': 'function',
                      'function': {'name': 'func_2', 'arguments': ''},
                  },
              ]
          },
          'finish_reason': None,
      }]
  }
  # the tool_call type is optional in chunk responses.
  chunk_data_2 = {
      'choices': [{
          'index': 0,
          'delta': {
              'tool_calls': [
                  {'index': 0, 'function': {'arguments': '{"arg": 1}'}},
                  {'index': 1, 'function': {'arguments': '{"arg": 2}'}},
              ]
          },
          'finish_reason': None,
      }]
  }
  chunk_data_3 = {
      'choices': [{'index': 0, 'delta': {}, 'finish_reason': 'tool_calls'}]
  }

  chunks = [
      f'{json.dumps(chunk_data_1)}\n',
      f'{json.dumps(chunk_data_2)}\n',
      f'{json.dumps(chunk_data_3)}\n',
  ]

  async def mock_aiter_lines():
    for chunk in chunks:
      yield chunk

  mock_response = AsyncMock(spec=httpx.Response)
  mock_response.aiter_lines.return_value = mock_aiter_lines()
  mock_response.status_code = 200

  mock_stream_ctx = mock.AsyncMock()
  mock_stream_ctx.__aenter__.return_value = mock_response

  with mock.patch.object(
      httpx.AsyncClient, 'stream', return_value=mock_stream_ctx
  ):
    responses = [
        r
        async for r in local_client.generate_content_async(
            llm_request, stream=True
        )
    ]

    assert len(responses) == 4
    parts = responses[-1].content.parts
    assert len(parts) == 2

    assert parts[0].function_call.name == 'func_1'
    assert parts[0].function_call.args == {'arg': 1}
    assert parts[0].function_call.id == 'call_1'

    assert parts[1].function_call.name == 'func_2'
    assert parts[1].function_call.args == {'arg': 2}

    assert parts[1].function_call.id == 'call_2'


@pytest.mark.asyncio
@pytest.mark.parametrize(
    ('chunks', 'expected_response_count'),
    [
        (
            [
                '\n',
                '   \n',
                (
                    'data: {"choices": [{"index": 0, "delta": {"content":'
                    ' "Hello"}, "finish_reason": null}]}\n'
                ),
            ],
            1,
        ),
        (
            [
                (
                    'data: {"choices": [{"index": 0, "delta": {"content":'
                    ' "Hello"}, "finish_reason": null}]}\n'
                ),
                '[DONE]\n',
                (
                    'data: {"choices": [{"index": 0, "delta": {"content":'
                    ' "World"}, "finish_reason": "stop"}]}\n'
                ),
            ],
            1,  # Should stop after [DONE]
        ),
        (
            [
                (
                    'data: {"choices": [{"index": 0, "delta": {"content":'
                    ' "Hello"}, "finish_reason": null}]}\n'
                ),
                '   [DONE]   \n',
                (
                    'data: {"choices": [{"index": 0, "delta": {"content":'
                    ' "World"}, "finish_reason": "stop"}]}\n'
                ),
            ],
            1,  # Should stop after [DONE]
        ),
        (
            [
                (
                    'data: {"choices": [{"index": 0, "delta": {"content":'
                    ' "Hello"}, "finish_reason": null}]}\n'
                ),
                'data: [DONE]\n',
                (
                    'data: {"choices": [{"index": 0, "delta": {"content":'
                    ' "World"}, "finish_reason": "stop"}]}\n'
                ),
            ],
            1,  # Should stop after [DONE]
        ),
    ],
)
async def test_generate_content_async_streaming_parse_lines(
    chunks, expected_response_count
):
  local_client = CompletionsHTTPClient(base_url='https://localhost')
  llm_request = LlmRequest(
      model='apigee/test',
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='hi')])
      ],
  )

  async def mock_aiter_lines():
    for chunk in chunks:
      yield chunk

  mock_response = AsyncMock(spec=httpx.Response)
  mock_response.aiter_lines.return_value = mock_aiter_lines()
  mock_response.status_code = 200

  mock_stream_ctx = mock.AsyncMock()
  mock_stream_ctx.__aenter__.return_value = mock_response

  with mock.patch.object(
      httpx.AsyncClient, 'stream', return_value=mock_stream_ctx
  ):
    responses = [
        r
        async for r in local_client.generate_content_async(
            llm_request, stream=True
        )
    ]
    assert len(responses) == expected_response_count
    assert responses[0].content.parts[0].text == 'Hello'


def test_process_chunk_with_refusal_streaming():
  handler = ChatCompletionsResponseHandler()

  chunk1 = {
      'choices': [{
          'delta': {
              'role': 'assistant',
              'content': 'Hello',
          },
          'index': 0,
      }]
  }
  responses1 = list(handler.process_chunk(chunk1))
  assert len(responses1) == 1
  assert responses1[0].content.parts[0].text == 'Hello'

  chunk2 = {
      'choices': [{
          'delta': {
              'refusal': 'I refuse',
          },
          'index': 0,
      }]
  }
  responses2 = list(handler.process_chunk(chunk2))
  assert len(responses2) == 1
  assert responses2[0].content.parts[0].text == '\n[[REFUSAL]]: I refuse'

  chunk3 = {
      'choices': [{
          'delta': {
              'refusal': ' to answer',
          },
          'index': 0,
      }]
  }
  responses3 = list(handler.process_chunk(chunk3))
  assert len(responses3) == 1
  assert responses3[0].content.parts[0].text == ' to answer'

  chunk4 = {
      'choices': [{
          'delta': {},
          'finish_reason': 'stop',
          'index': 0,
      }]
  }
  responses4 = list(handler.process_chunk(chunk4))
  assert len(responses4) == 2
  final_response = responses4[1]
  assert final_response.finish_reason == types.FinishReason.STOP
  assert (
      final_response.content.parts[0].text
      == 'Hello\n[[REFUSAL]]: I refuse to answer'
  )
