/**
 * Google Adapter Tests
 * Verifies model options handling, message translation, and configuration
 */

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { GoogleAdapter, isGoogleModel } from '../server/google'
import { LLMResponseParseError } from '../../errors'
import type { LLMGenerateRequest } from '../../types'

// Mock Google GenAI SDK
const mockGenerateContent = vi.fn()

vi.mock('@google/genai', () => ({
  GoogleGenAI: vi.fn().mockImplementation(() => ({
    models: {
      generateContent: mockGenerateContent
    }
  }))
}))

describe('GoogleAdapter', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  const baseRequest: LLMGenerateRequest = {
    messages: [
      { role: 'system', content: 'You are a helpful assistant' },
      { role: 'user', content: 'Hello' }
    ],
    responseSchema: {
      type: 'object',
      properties: {
        response: { type: 'string' }
      }
    },
    maxOutputTokens: 1000
  }

  const mockGoogleResponse = {
    text: JSON.stringify({ response: 'Hello! How can I help you?' }),
    usageMetadata: {
      promptTokenCount: 10,
      candidatesTokenCount: 20,
      totalTokenCount: 30
    }
  }

  describe('isGoogleModel', () => {
    it('returns true for gemini models with google provider', () => {
      expect(isGoogleModel('gemini-3-flash-preview', 'google')).toBe(true)
    })

    it('returns false for gemini models with other providers', () => {
      expect(isGoogleModel('gemini-3-flash-preview', 'openrouter')).toBe(false)
    })

    it('returns false for non-gemini models', () => {
      expect(isGoogleModel('gpt-5', 'google')).toBe(false)
    })
  })

  describe('Basic Configuration', () => {
    it('creates adapter without model options', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      await adapter.generate(baseRequest)

      // Verify call was made
      expect(mockGenerateContent).toHaveBeenCalledTimes(1)
      const callParams = mockGenerateContent.mock.calls[0][0]

      // Verify no thinking config when not specified
      expect(callParams.config).not.toHaveProperty('thinkingConfig')
    })

    it('creates adapter with thinkingLevel option', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview',
        modelOptions: {
          thinkingLevel: 'medium'
        }
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      await adapter.generate(baseRequest)

      const callParams = mockGenerateContent.mock.calls[0][0]
      expect(callParams.config.thinkingConfig).toEqual({
        thinkingLevel: 'medium'
      })
    })
  })

  describe('Thinking Level Options', () => {
    it.each(['minimal', 'low', 'medium', 'high'] as const)('supports %s thinking level', async (level) => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview',
        modelOptions: {
          thinkingLevel: level
        }
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      await adapter.generate(baseRequest)

      const callParams = mockGenerateContent.mock.calls[0][0]
      expect(callParams.config.thinkingConfig.thinkingLevel).toBe(level)
    })
  })

  describe('Message Translation', () => {
    it('converts system messages to systemInstruction', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      await adapter.generate({
        messages: [
          { role: 'system', content: 'You are helpful' },
          { role: 'user', content: 'Hello' }
        ],
        responseSchema: baseRequest.responseSchema,
        maxOutputTokens: 1000
      })

      const callParams = mockGenerateContent.mock.calls[0][0]

      // System message should be in systemInstruction
      expect(callParams.config.systemInstruction).toBe('You are helpful')

      // Contents should only have user message
      expect(callParams.contents).toHaveLength(1)
      expect(callParams.contents[0].role).toBe('user')
    })

    it('combines multiple system messages', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      await adapter.generate({
        messages: [
          { role: 'system', content: 'You are helpful' },
          { role: 'system', content: 'Be concise' },
          { role: 'user', content: 'Hello' }
        ],
        responseSchema: baseRequest.responseSchema,
        maxOutputTokens: 1000
      })

      const callParams = mockGenerateContent.mock.calls[0][0]
      expect(callParams.config.systemInstruction).toBe('You are helpful\n\nBe concise')
    })

    it('converts assistant role to model role', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      await adapter.generate({
        messages: [
          { role: 'user', content: 'Hello' },
          { role: 'assistant', content: 'Hi there!' },
          { role: 'user', content: 'How are you?' }
        ],
        responseSchema: baseRequest.responseSchema,
        maxOutputTokens: 1000
      })

      const callParams = mockGenerateContent.mock.calls[0][0]
      expect(callParams.contents[0].role).toBe('user')
      expect(callParams.contents[1].role).toBe('model')
      expect(callParams.contents[2].role).toBe('user')
    })
  })

  describe('Structured Output', () => {
    it('configures JSON response format with schema', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      await adapter.generate(baseRequest)

      const callParams = mockGenerateContent.mock.calls[0][0]
      expect(callParams.config.responseMimeType).toBe('application/json')
      expect(callParams.config.responseSchema).toEqual(baseRequest.responseSchema)
    })

    it('parses JSON response correctly', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce({
        text: JSON.stringify({ answer: 'Paris', confidence: 0.95 }),
        usageMetadata: mockGoogleResponse.usageMetadata
      })

      const response = await adapter.generate<{ answer: string; confidence: number }>({
        messages: [{ role: 'user', content: 'What is the capital of France?' }],
        responseSchema: {
          type: 'object',
          properties: {
            answer: { type: 'string' },
            confidence: { type: 'number' }
          }
        },
        maxOutputTokens: 500
      })

      expect(response.output.answer).toBe('Paris')
      expect(response.output.confidence).toBe(0.95)
    })
  })

  describe('Usage Metadata', () => {
    it('extracts token usage from response', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      const response = await adapter.generate(baseRequest)

      expect(response.usage).toEqual({
        inputTokens: 10,
        outputTokens: 20,
        totalTokens: 30
      })
    })

    it('handles missing usage metadata gracefully', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce({
        text: JSON.stringify({ response: 'Hello!' })
        // No usageMetadata
      })

      const response = await adapter.generate(baseRequest)

      expect(response.usage).toBeUndefined()
    })
  })

  describe('MaxTokens Configuration', () => {
    it('passes maxOutputTokens to Google API', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      await adapter.generate({
        ...baseRequest,
        maxOutputTokens: 2000
      })

      const callParams = mockGenerateContent.mock.calls[0][0]
      expect(callParams.config.maxOutputTokens).toBe(2000)
    })

    it('omits maxOutputTokens when not specified', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      await adapter.generate({
        messages: baseRequest.messages,
        responseSchema: baseRequest.responseSchema
        // No maxOutputTokens
      })

      const callParams = mockGenerateContent.mock.calls[0][0]
      expect(callParams.config.maxOutputTokens).toBeUndefined()
    })
  })

  describe('Backward Compatibility', () => {
    it('works with existing code that does not provide model options', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
        // No modelOptions field
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      const response = await adapter.generate(baseRequest)

      expect(response.output).toEqual({ response: 'Hello! How can I help you?' })
      expect(response.usage).toEqual({
        inputTokens: 10,
        outputTokens: 20,
        totalTokens: 30
      })
    })

    it('handles empty model options object', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview',
        modelOptions: {}
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      await adapter.generate(baseRequest)

      const callParams = mockGenerateContent.mock.calls[0][0]
      expect(callParams.config).not.toHaveProperty('thinkingConfig')
    })
  })

  describe('Multiple Calls', () => {
    it('applies same model options to multiple generate calls', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview',
        modelOptions: {
          thinkingLevel: 'high'
        }
      })

      mockGenerateContent.mockResolvedValue(mockGoogleResponse)

      // First call
      await adapter.generate(baseRequest)
      expect(mockGenerateContent.mock.calls[0][0].config.thinkingConfig.thinkingLevel).toBe('high')

      // Second call
      await adapter.generate(baseRequest)
      expect(mockGenerateContent.mock.calls[1][0].config.thinkingConfig.thinkingLevel).toBe('high')
    })
  })

  describe('Error Handling', () => {
    it('propagates API errors', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      const apiError = new Error('API rate limit exceeded')
      mockGenerateContent.mockRejectedValueOnce(apiError)

      await expect(adapter.generate(baseRequest)).rejects.toThrow('API rate limit exceeded')
    })

    it('throws LLMResponseParseError for empty response text with diagnostic details', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce({
        text: '',
        usageMetadata: mockGoogleResponse.usageMetadata
      })

      await expect(adapter.generate(baseRequest)).rejects.toThrow(
        expect.objectContaining({
          message: expect.stringContaining('Google Gemini returned no output')
        })
      )
    })

    it('includes block reason when prompt is blocked', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce({
        text: undefined,
        promptFeedback: { blockReason: 'SAFETY' },
        candidates: [],
        usageMetadata: mockGoogleResponse.usageMetadata
      })

      await expect(adapter.generate(baseRequest)).rejects.toThrow(/prompt blocked: SAFETY/)
    })

    it('includes finish reason when candidate is not STOP', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce({
        text: undefined,
        candidates: [{ finishReason: 'SAFETY', content: { parts: [] } }],
        usageMetadata: mockGoogleResponse.usageMetadata
      })

      await expect(adapter.generate(baseRequest)).rejects.toThrow(/finish reason: SAFETY/)
    })
  })

  describe('JSON Parse Error Handling', () => {
    it('throws LLMResponseParseError when response is invalid JSON', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce({
        text: '{"response": "incomplete json', // Invalid JSON - unterminated string
        usageMetadata: mockGoogleResponse.usageMetadata
      })

      await expect(adapter.generate(baseRequest)).rejects.toThrow(LLMResponseParseError)
    })

    it('includes truncated raw content in LLMResponseParseError context', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce({
        text: '{"response": "incomplete',
        usageMetadata: mockGoogleResponse.usageMetadata
      })

      try {
        await adapter.generate(baseRequest)
        expect.fail('Should have thrown')
      } catch (error) {
        expect(error).toBeInstanceOf(LLMResponseParseError)
        const parseError = error as LLMResponseParseError
        expect(parseError.context).toHaveProperty('rawContent')
        expect(parseError.context).toHaveProperty('parseError')
        expect(parseError.message).toContain('Failed to parse LLM response as JSON')
      }
    })

    it('parses valid JSON successfully', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      const result = await adapter.generate(baseRequest)
      expect(result.output).toEqual({ response: 'Hello! How can I help you?' })
    })
  })

  describe('Schema Transformation for Gemini', () => {
    it('transforms const to enum with type string in responseSchema', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      // Schema with const (from z.literal())
      await adapter.generate({
        messages: [{ role: 'user', content: 'Test' }],
        responseSchema: {
          type: 'object',
          properties: {
            type: { const: 'tool-call' }, // const should become type: "string", enum: ["tool-call"]
            name: { type: 'string' }
          }
        },
        maxOutputTokens: 1000
      })

      const callParams = mockGenerateContent.mock.calls[0][0]

      // Verify const was transformed to type + enum (Gemini requires type: "string" for enum)
      expect(callParams.config.responseSchema.properties.type).toEqual({ type: 'string', enum: ['tool-call'] })
      expect(callParams.config.responseSchema.properties.type).not.toHaveProperty('const')
    })

    it('transforms nested const in anyOf schema', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      // Schema with anyOf containing const (discriminated union pattern)
      await adapter.generate({
        messages: [{ role: 'user', content: 'Test' }],
        responseSchema: {
          type: 'object',
          properties: {
            action: {
              anyOf: [
                { type: 'object', properties: { type: { const: 'complete' } } },
                { type: 'object', properties: { type: { const: 'tool-call' }, name: { type: 'string' } } }
              ]
            }
          }
        },
        maxOutputTokens: 1000
      })

      const callParams = mockGenerateContent.mock.calls[0][0]
      const anyOf = callParams.config.responseSchema.properties.action.anyOf

      // Verify both const values were transformed to type + enum
      expect(anyOf[0].properties.type).toEqual({ type: 'string', enum: ['complete'] })
      expect(anyOf[1].properties.type).toEqual({ type: 'string', enum: ['tool-call'] })
    })

    it('removes $schema property', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      await adapter.generate({
        messages: [{ role: 'user', content: 'Test' }],
        responseSchema: {
          $schema: 'http://json-schema.org/draft-07/schema#',
          type: 'object',
          properties: {
            name: { type: 'string' }
          }
        },
        maxOutputTokens: 1000
      })

      const callParams = mockGenerateContent.mock.calls[0][0]

      // Verify $schema was removed
      expect(callParams.config.responseSchema).not.toHaveProperty('$schema')
      expect(callParams.config.responseSchema.type).toBe('object')
    })

    it('inlines $ref definitions from $defs', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      // Schema with $ref and $defs
      await adapter.generate({
        messages: [{ role: 'user', content: 'Test' }],
        responseSchema: {
          type: 'object',
          properties: {
            item: { $ref: '#/$defs/Item' }
          },
          $defs: {
            Item: {
              type: 'object',
              properties: {
                id: { type: 'string' },
                value: { type: 'number' }
              }
            }
          }
        },
        maxOutputTokens: 1000
      })

      const callParams = mockGenerateContent.mock.calls[0][0]

      // Verify $ref was inlined
      expect(callParams.config.responseSchema.properties.item).toEqual({
        type: 'object',
        properties: {
          id: { type: 'string' },
          value: { type: 'number' }
        }
      })
      // Verify $defs was removed
      expect(callParams.config.responseSchema).not.toHaveProperty('$defs')
    })

    it('handles deeply nested schemas with const', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      await adapter.generate({
        messages: [{ role: 'user', content: 'Test' }],
        responseSchema: {
          type: 'object',
          properties: {
            data: {
              type: 'array',
              items: {
                type: 'object',
                properties: {
                  status: { const: 'active' }
                }
              }
            }
          }
        },
        maxOutputTokens: 1000
      })

      const callParams = mockGenerateContent.mock.calls[0][0]

      // Verify nested const was transformed to type + enum
      expect(callParams.config.responseSchema.properties.data.items.properties.status).toEqual({
        type: 'string',
        enum: ['active']
      })
    })

    it('preserves other schema properties during transformation', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      await adapter.generate({
        messages: [{ role: 'user', content: 'Test' }],
        responseSchema: {
          type: 'object',
          properties: {
            name: { type: 'string', description: 'User name', minLength: 1 },
            age: { type: 'integer', minimum: 0, maximum: 150 },
            role: { const: 'admin' }
          },
          required: ['name', 'role'],
          additionalProperties: false
        },
        maxOutputTokens: 1000
      })

      const callParams = mockGenerateContent.mock.calls[0][0]
      const schema = callParams.config.responseSchema

      // Verify structure preserved
      expect(schema.type).toBe('object')
      expect(schema.required).toEqual(['name', 'role'])
      expect(schema.additionalProperties).toBe(false)

      // Verify property details preserved
      expect(schema.properties.name).toEqual({ type: 'string', description: 'User name', minLength: 1 })
      expect(schema.properties.age).toEqual({ type: 'integer', minimum: 0, maximum: 150 })

      // Verify const was transformed to type + enum
      expect(schema.properties.role).toEqual({ type: 'string', enum: ['admin'] })
    })

    it('adds placeholder property to empty object schemas', async () => {
      const adapter = new GoogleAdapter({
        apiKey: 'test-key',
        model: 'gemini-3-flash-preview'
      })

      mockGenerateContent.mockResolvedValueOnce(mockGoogleResponse)

      await adapter.generate({
        messages: [{ role: 'user', content: 'Test' }],
        responseSchema: {
          type: 'object',
          properties: {
            data: {
              type: 'object',
              properties: {} // Empty properties - Gemini rejects this
            }
          }
        },
        maxOutputTokens: 1000
      })

      const callParams = mockGenerateContent.mock.calls[0][0]

      // Verify placeholder was added to empty object
      // Uses 'placeholderField' - a name Google accepts (no underscore prefix)
      expect(callParams.config.responseSchema.properties.data.properties).toHaveProperty('placeholderField')
      expect(callParams.config.responseSchema.properties.data.properties.placeholderField.type).toBe('string')
    })
  })
})
