import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
  OpencodeProvider,
  resetToolUseIdCounter,
} from '../../../src/providers/opencode-provider.js';
import type { ProviderMessage, ModelDefinition } from '@automaker/types';
import { collectAsyncGenerator } from '../../utils/helpers.js';
import { spawnJSONLProcess, getOpenCodeAuthIndicators } from '@automaker/platform';

vi.mock('@automaker/platform', () => ({
  spawnJSONLProcess: vi.fn(),
  isWslAvailable: vi.fn().mockReturnValue(false),
  findCliInWsl: vi.fn().mockReturnValue(null),
  createWslCommand: vi.fn(),
  windowsToWslPath: vi.fn(),
  getOpenCodeAuthIndicators: vi.fn().mockResolvedValue({
    hasAuthFile: false,
    hasOAuthToken: false,
    hasApiKey: false,
  }),
}));

describe('opencode-provider.ts', () => {
  let provider: OpencodeProvider;

  beforeEach(() => {
    vi.clearAllMocks();
    resetToolUseIdCounter();
    provider = new OpencodeProvider();
  });

  afterEach(() => {
    // Note: Don't use vi.restoreAllMocks() here as it would undo the module-level
    // mock implementations (like getOpenCodeAuthIndicators) set up with vi.mock()
  });

  // ==========================================================================
  // Basic Provider Tests
  // ==========================================================================

  describe('getName', () => {
    it("should return 'opencode' as provider name", () => {
      expect(provider.getName()).toBe('opencode');
    });
  });

  describe('getCliName', () => {
    it("should return 'opencode' as CLI name", () => {
      expect(provider.getCliName()).toBe('opencode');
    });
  });

  describe('getAvailableModels', () => {
    it('should return 5 models', () => {
      const models = provider.getAvailableModels();
      expect(models).toHaveLength(5);
    });

    it('should include Big Pickle as default', () => {
      const models = provider.getAvailableModels();
      const bigPickle = models.find((m) => m.id === 'opencode/big-pickle');

      expect(bigPickle).toBeDefined();
      expect(bigPickle?.name).toBe('Big Pickle (Free)');
      expect(bigPickle?.provider).toBe('opencode');
      expect(bigPickle?.default).toBe(true);
      expect(bigPickle?.modelString).toBe('opencode/big-pickle');
    });

    it('should include free tier GLM model', () => {
      const models = provider.getAvailableModels();
      const glm = models.find((m) => m.id === 'opencode/glm-4.7-free');

      expect(glm).toBeDefined();
      expect(glm?.name).toBe('GLM 4.7 Free');
      expect(glm?.tier).toBe('basic');
    });

    it('should include free tier MiniMax model', () => {
      const models = provider.getAvailableModels();
      const minimax = models.find((m) => m.id === 'opencode/minimax-m2.1-free');

      expect(minimax).toBeDefined();
      expect(minimax?.name).toBe('MiniMax M2.1 Free');
      expect(minimax?.tier).toBe('basic');
    });

    it('should have all models support tools', () => {
      const models = provider.getAvailableModels();

      models.forEach((model) => {
        expect(model.supportsTools).toBe(true);
      });
    });

    it('should have models with modelString property', () => {
      const models = provider.getAvailableModels();

      for (const model of models) {
        expect(model).toHaveProperty('modelString');
        expect(typeof model.modelString).toBe('string');
      }
    });
  });

  describe('parseModelsOutput', () => {
    it('should parse nested provider model IDs', () => {
      const output = ['openrouter/anthropic/claude-3.5-sonnet', 'openai/gpt-4o'].join('\n');

      const parseModelsOutput = (
        provider as unknown as { parseModelsOutput: (output: string) => ModelDefinition[] }
      ).parseModelsOutput.bind(provider);
      const models = parseModelsOutput(output);

      expect(models).toHaveLength(2);
      const openrouterModel = models.find((model) => model.id.startsWith('openrouter/'));

      expect(openrouterModel).toBeDefined();
      expect(openrouterModel?.provider).toBe('openrouter');
      expect(openrouterModel?.modelString).toBe('openrouter/anthropic/claude-3.5-sonnet');
    });
  });

  describe('supportsFeature', () => {
    it("should support 'tools' feature", () => {
      expect(provider.supportsFeature('tools')).toBe(true);
    });

    it("should support 'text' feature", () => {
      expect(provider.supportsFeature('text')).toBe(true);
    });

    it("should support 'vision' feature", () => {
      expect(provider.supportsFeature('vision')).toBe(true);
    });

    it("should not support 'thinking' feature", () => {
      expect(provider.supportsFeature('thinking')).toBe(false);
    });

    it("should not support 'mcp' feature", () => {
      expect(provider.supportsFeature('mcp')).toBe(false);
    });

    it("should not support 'cli' feature", () => {
      expect(provider.supportsFeature('cli')).toBe(false);
    });

    it('should return false for unknown features', () => {
      expect(provider.supportsFeature('unknown-feature')).toBe(false);
      expect(provider.supportsFeature('nonexistent')).toBe(false);
      expect(provider.supportsFeature('')).toBe(false);
    });
  });

  // ==========================================================================
  // buildCliArgs Tests
  // ==========================================================================

  describe('buildCliArgs', () => {
    it('should build correct args with run subcommand', () => {
      const args = provider.buildCliArgs({
        prompt: 'Hello',
        model: 'opencode/big-pickle',
        cwd: '/tmp/project',
      });

      expect(args[0]).toBe('run');
    });

    it('should include --format json for streaming output', () => {
      const args = provider.buildCliArgs({
        prompt: 'Hello',
        model: 'opencode/big-pickle',
        cwd: '/tmp/project',
      });

      const formatIndex = args.indexOf('--format');
      expect(formatIndex).toBeGreaterThan(-1);
      expect(args[formatIndex + 1]).toBe('json');
    });

    it('should include model with --model flag', () => {
      const args = provider.buildCliArgs({
        prompt: 'Hello',
        model: 'anthropic/claude-sonnet-4-5',
        cwd: '/tmp/project',
      });

      const modelIndex = args.indexOf('--model');
      expect(modelIndex).toBeGreaterThan(-1);
      expect(args[modelIndex + 1]).toBe('anthropic/claude-sonnet-4-5');
    });

    it('should strip opencode- prefix from model', () => {
      const args = provider.buildCliArgs({
        prompt: 'Hello',
        model: 'opencode-anthropic/claude-sonnet-4-5',
        cwd: '/tmp/project',
      });

      const modelIndex = args.indexOf('--model');
      expect(args[modelIndex + 1]).toBe('anthropic/claude-sonnet-4-5');
    });

    it('should handle missing cwd', () => {
      const args = provider.buildCliArgs({
        prompt: 'Hello',
        model: 'opencode/big-pickle',
      });

      expect(args).not.toContain('-c');
    });

    it('should handle model from opencode provider', () => {
      const args = provider.buildCliArgs({
        prompt: 'Hello',
        model: 'opencode/big-pickle',
        cwd: '/tmp/project',
      });

      expect(args).toContain('--model');
      expect(args).toContain('opencode/big-pickle');
    });
  });

  // ==========================================================================
  // normalizeEvent Tests
  // ==========================================================================

  describe('normalizeEvent', () => {
    describe('text events (new OpenCode format)', () => {
      it('should convert text to assistant message with text content', () => {
        const event = {
          type: 'text',
          part: {
            type: 'text',
            text: 'Hello, world!',
          },
          sessionID: 'test-session',
        };

        const result = provider.normalizeEvent(event);

        expect(result).toEqual({
          type: 'assistant',
          session_id: 'test-session',
          message: {
            role: 'assistant',
            content: [
              {
                type: 'text',
                text: 'Hello, world!',
              },
            ],
          },
        });
      });

      it('should return null for empty text', () => {
        const event = {
          type: 'text',
          part: {
            type: 'text',
            text: '',
          },
        };

        const result = provider.normalizeEvent(event);

        expect(result).toBeNull();
      });

      it('should return null for text with undefined text', () => {
        const event = {
          type: 'text',
          part: {},
        };

        const result = provider.normalizeEvent(event);

        expect(result).toBeNull();
      });
    });

    describe('tool_call events', () => {
      it('should convert tool_call to assistant message with tool_use content', () => {
        const event = {
          type: 'tool_call',
          part: {
            type: 'tool-call',
            call_id: 'call-123',
            name: 'Read',
            args: { file_path: '/tmp/test.txt' },
          },
          sessionID: 'test-session',
        };

        const result = provider.normalizeEvent(event);

        expect(result).toEqual({
          type: 'assistant',
          session_id: 'test-session',
          message: {
            role: 'assistant',
            content: [
              {
                type: 'tool_use',
                name: 'Read',
                tool_use_id: 'call-123',
                input: { file_path: '/tmp/test.txt' },
              },
            ],
          },
        });
      });

      it('should generate tool_use_id when call_id is missing', () => {
        const event = {
          type: 'tool_call',
          part: {
            type: 'tool-call',
            name: 'Write',
            args: { content: 'test' },
          },
        };

        const result = provider.normalizeEvent(event);

        expect(result?.message?.content[0].type).toBe('tool_use');
        expect(result?.message?.content[0].tool_use_id).toBe('opencode-tool-1');

        // Second call should increment
        const result2 = provider.normalizeEvent({
          type: 'tool_call',
          part: {
            type: 'tool-call',
            name: 'Edit',
            args: {},
          },
        });
        expect(result2?.message?.content[0].tool_use_id).toBe('opencode-tool-2');
      });
    });

    describe('tool_result events', () => {
      it('should convert tool_result to assistant message with tool_result content', () => {
        const event = {
          type: 'tool_result',
          part: {
            type: 'tool-result',
            call_id: 'call-123',
            output: 'File contents here',
          },
          sessionID: 'test-session',
        };

        const result = provider.normalizeEvent(event);

        expect(result).toEqual({
          type: 'assistant',
          session_id: 'test-session',
          message: {
            role: 'assistant',
            content: [
              {
                type: 'tool_result',
                tool_use_id: 'call-123',
                content: 'File contents here',
              },
            ],
          },
        });
      });

      it('should handle tool_result without call_id', () => {
        const event = {
          type: 'tool_result',
          part: {
            type: 'tool-result',
            output: 'Result without ID',
          },
        };

        const result = provider.normalizeEvent(event);

        expect(result?.message?.content[0].type).toBe('tool_result');
        expect(result?.message?.content[0].tool_use_id).toBeUndefined();
      });
    });

    describe('tool_error events', () => {
      it('should convert tool_error to error message', () => {
        const event = {
          type: 'tool_error',
          part: {
            type: 'tool-error',
            call_id: 'call-123',
            error: 'File not found',
          },
          sessionID: 'test-session',
        };

        const result = provider.normalizeEvent(event);

        expect(result).toEqual({
          type: 'error',
          session_id: 'test-session',
          error: 'File not found',
        });
      });

      it('should provide default error message when error is missing', () => {
        const event = {
          type: 'tool_error',
          part: {
            type: 'tool-error',
            call_id: 'call-123',
          },
        };

        const result = provider.normalizeEvent(event);

        expect(result?.type).toBe('error');
        expect(result?.error).toBe('Tool execution failed');
      });
    });

    describe('step_start events', () => {
      it('should return null for step_start events (informational)', () => {
        const event = {
          type: 'step_start',
          part: {
            type: 'step-start',
          },
          sessionID: 'test-session',
        };

        const result = provider.normalizeEvent(event);

        expect(result).toBeNull();
      });
    });

    describe('step_finish events', () => {
      it('should convert successful step_finish to result message', () => {
        const event = {
          type: 'step_finish',
          part: {
            type: 'step-finish',
            reason: 'stop',
            result: 'Task completed successfully',
          },
          sessionID: 'test-session',
        };

        const result = provider.normalizeEvent(event);

        expect(result).toEqual({
          type: 'result',
          subtype: 'success',
          session_id: 'test-session',
          result: 'Task completed successfully',
        });
      });

      it('should convert step_finish with error to error message', () => {
        const event = {
          type: 'step_finish',
          part: {
            type: 'step-finish',
            reason: 'error',
            error: 'Something went wrong',
          },
          sessionID: 'test-session',
        };

        const result = provider.normalizeEvent(event);

        expect(result).toEqual({
          type: 'error',
          session_id: 'test-session',
          error: 'Something went wrong',
        });
      });

      it('should convert step_finish with error property to error message', () => {
        const event = {
          type: 'step_finish',
          part: {
            type: 'step-finish',
            error: 'Process failed',
          },
        };

        const result = provider.normalizeEvent(event);

        expect(result?.type).toBe('error');
        expect(result?.error).toBe('Process failed');
      });

      it('should provide default error message for failed step without error text', () => {
        const event = {
          type: 'step_finish',
          part: {
            type: 'step-finish',
            reason: 'error',
          },
        };

        const result = provider.normalizeEvent(event);

        expect(result?.type).toBe('error');
        expect(result?.error).toBe('Step execution failed');
      });

      it('should treat step_finish with reason=stop as success', () => {
        const event = {
          type: 'step_finish',
          part: {
            type: 'step-finish',
            reason: 'stop',
            result: 'Done',
          },
        };

        const result = provider.normalizeEvent(event);

        expect(result?.type).toBe('result');
        expect(result?.subtype).toBe('success');
      });
    });

    describe('unknown events', () => {
      it('should return null for unknown event types', () => {
        const event = {
          type: 'unknown-event',
          data: 'some data',
        };

        const result = provider.normalizeEvent(event);

        expect(result).toBeNull();
      });

      it('should return null for null input', () => {
        const result = provider.normalizeEvent(null);
        expect(result).toBeNull();
      });

      it('should return null for undefined input', () => {
        const result = provider.normalizeEvent(undefined);
        expect(result).toBeNull();
      });

      it('should return null for non-object input', () => {
        expect(provider.normalizeEvent('string')).toBeNull();
        expect(provider.normalizeEvent(123)).toBeNull();
        expect(provider.normalizeEvent(true)).toBeNull();
      });

      it('should return null for events without type', () => {
        expect(provider.normalizeEvent({})).toBeNull();
        expect(provider.normalizeEvent({ data: 'no type' })).toBeNull();
      });
    });
  });

  // ==========================================================================
  // executeQuery Tests
  // ==========================================================================

  describe('executeQuery', () => {
    /**
     * Helper to set up the provider with a mocked CLI path
     * This bypasses CLI detection for testing
     */
    function setupMockedProvider(): OpencodeProvider {
      const mockedProvider = new OpencodeProvider();
      // Access protected property to simulate CLI detection
      (mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode';
      (mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native';
      return mockedProvider;
    }

    it('should stream text events as assistant messages', async () => {
      const mockedProvider = setupMockedProvider();

      const mockEvents = [
        { type: 'text', part: { type: 'text', text: 'Hello ' } },
        { type: 'text', part: { type: 'text', text: 'World!' } },
      ];

      vi.mocked(spawnJSONLProcess).mockReturnValue(
        (async function* () {
          for (const event of mockEvents) {
            yield event;
          }
        })()
      );

      const results = await collectAsyncGenerator<ProviderMessage>(
        mockedProvider.executeQuery({
          prompt: 'Say hello',
          model: 'anthropic/claude-sonnet-4-5',
          cwd: '/tmp',
        })
      );

      expect(results).toHaveLength(2);
      expect(results[0].type).toBe('assistant');
      expect(results[0].message?.content[0].text).toBe('Hello ');
      expect(results[1].message?.content[0].text).toBe('World!');
    });

    it('should emit tool_use and tool_result with matching IDs', async () => {
      const mockedProvider = setupMockedProvider();

      const mockEvents = [
        {
          type: 'tool_call',
          part: {
            type: 'tool-call',
            call_id: 'tool-1',
            name: 'Read',
            args: { file_path: '/tmp/test.txt' },
          },
        },
        {
          type: 'tool_result',
          part: {
            type: 'tool-result',
            call_id: 'tool-1',
            output: 'File contents',
          },
        },
      ];

      vi.mocked(spawnJSONLProcess).mockReturnValue(
        (async function* () {
          for (const event of mockEvents) {
            yield event;
          }
        })()
      );

      const results = await collectAsyncGenerator<ProviderMessage>(
        mockedProvider.executeQuery({
          prompt: 'Read a file',
          cwd: '/tmp',
        })
      );

      expect(results).toHaveLength(2);

      const toolUse = results[0];
      const toolResult = results[1];

      expect(toolUse.type).toBe('assistant');
      expect(toolUse.message?.content[0].type).toBe('tool_use');
      expect(toolUse.message?.content[0].tool_use_id).toBe('tool-1');

      expect(toolResult.type).toBe('assistant');
      expect(toolResult.message?.content[0].type).toBe('tool_result');
      expect(toolResult.message?.content[0].tool_use_id).toBe('tool-1');
    });

    it('should pass stdinData containing the prompt', async () => {
      const mockedProvider = setupMockedProvider();

      vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());

      await collectAsyncGenerator(
        mockedProvider.executeQuery({
          prompt: 'My test prompt',
          cwd: '/tmp',
        })
      );

      const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
      expect(call.stdinData).toBe('My test prompt');
    });

    it('should extract text from array prompt', async () => {
      const mockedProvider = setupMockedProvider();

      vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());

      const arrayPrompt = [
        { type: 'text', text: 'First part' },
        { type: 'image', source: { type: 'base64', data: '...' } },
        { type: 'text', text: 'Second part' },
      ];

      await collectAsyncGenerator(
        mockedProvider.executeQuery({
          prompt: arrayPrompt as unknown as string,
          cwd: '/tmp',
        })
      );

      const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
      expect(call.stdinData).toBe('First part\nSecond part');
    });

    it('should include correct CLI args in subprocess options', async () => {
      const mockedProvider = setupMockedProvider();

      vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());

      await collectAsyncGenerator(
        mockedProvider.executeQuery({
          prompt: 'Test',
          model: 'opencode-anthropic/claude-opus-4-5',
          cwd: '/tmp/workspace',
        })
      );

      const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
      expect(call.args).toContain('run');
      expect(call.args).toContain('--format');
      expect(call.args).toContain('json');
      expect(call.args).toContain('--model');
      expect(call.args).toContain('anthropic/claude-opus-4-5');
    });

    it('should skip null-normalized events', async () => {
      const mockedProvider = setupMockedProvider();

      const mockEvents = [
        { type: 'unknown-internal-event', data: 'ignored' },
        { type: 'text', part: { type: 'text', text: 'Valid text' } },
        { type: 'another-unknown', foo: 'bar' },
        { type: 'step_finish', part: { type: 'step-finish', reason: 'stop', result: 'Done' } },
      ];

      vi.mocked(spawnJSONLProcess).mockReturnValue(
        (async function* () {
          for (const event of mockEvents) {
            yield event;
          }
        })()
      );

      const results = await collectAsyncGenerator<ProviderMessage>(
        mockedProvider.executeQuery({
          prompt: 'Test',
          model: 'opencode/big-pickle',
          cwd: '/test',
        })
      );

      // Should only have valid events (text and result), not the unknown ones
      expect(results.length).toBe(2);
    });

    it('should throw error when CLI is not installed', async () => {
      // Create provider and explicitly set cliPath to null to simulate not installed
      // Set detectedStrategy to 'npx' to prevent ensureCliDetected from re-running detection
      const unmockedProvider = new OpencodeProvider();
      (unmockedProvider as unknown as { cliPath: string | null }).cliPath = null;
      (unmockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx';

      await expect(
        collectAsyncGenerator(
          unmockedProvider.executeQuery({
            prompt: 'Test',
            cwd: '/test',
          })
        )
      ).rejects.toThrow(/CLI not found/);
    });
  });

  // ==========================================================================
  // getSpawnConfig Tests
  // ==========================================================================

  describe('getSpawnConfig', () => {
    it('should return npx as Windows strategy', () => {
      const config = provider.getSpawnConfig();
      expect(config.windowsStrategy).toBe('npx');
    });

    it('should specify opencode-ai@latest as npx package', () => {
      const config = provider.getSpawnConfig();
      expect(config.npxPackage).toBe('opencode-ai@latest');
    });

    it('should include common paths for Linux', () => {
      const config = provider.getSpawnConfig();
      const linuxPaths = config.commonPaths['linux'];

      expect(linuxPaths).toBeDefined();
      expect(linuxPaths.length).toBeGreaterThan(0);
      expect(linuxPaths.some((p) => p.includes('opencode'))).toBe(true);
    });

    it('should include common paths for macOS', () => {
      const config = provider.getSpawnConfig();
      const darwinPaths = config.commonPaths['darwin'];

      expect(darwinPaths).toBeDefined();
      expect(darwinPaths.length).toBeGreaterThan(0);
      expect(darwinPaths.some((p) => p.includes('homebrew'))).toBe(true);
    });

    it('should include common paths for Windows', () => {
      const config = provider.getSpawnConfig();
      const win32Paths = config.commonPaths['win32'];

      expect(win32Paths).toBeDefined();
      expect(win32Paths.length).toBeGreaterThan(0);
      expect(win32Paths.some((p) => p.includes('npm'))).toBe(true);
    });
  });

  // ==========================================================================
  // detectInstallation Tests
  // ==========================================================================

  describe('detectInstallation', () => {
    beforeEach(() => {
      // Ensure the mock implementation is set up for each test
      vi.mocked(getOpenCodeAuthIndicators).mockResolvedValue({
        hasAuthFile: false,
        hasOAuthToken: false,
        hasApiKey: false,
      });
    });

    it('should return installed true when CLI is found', async () => {
      (provider as unknown as { cliPath: string }).cliPath = '/usr/local/bin/opencode';
      (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'native';

      const result = await provider.detectInstallation();

      expect(result.installed).toBe(true);
      expect(result.path).toBe('/usr/local/bin/opencode');
    });

    it('should return installed false when CLI is not found', async () => {
      // Set both cliPath to null and detectedStrategy to something other than 'native'
      // to prevent ensureCliDetected from re-detecting
      (provider as unknown as { cliPath: string | null }).cliPath = null;
      (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx';

      const result = await provider.detectInstallation();

      expect(result.installed).toBe(false);
    });

    it('should return method as npm when using npx strategy', async () => {
      (provider as unknown as { cliPath: string }).cliPath = 'npx';
      (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx';

      const result = await provider.detectInstallation();

      expect(result.method).toBe('npm');
    });

    it('should return method as cli when using native strategy', async () => {
      (provider as unknown as { cliPath: string }).cliPath = '/usr/local/bin/opencode';
      (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'native';

      const result = await provider.detectInstallation();

      expect(result.method).toBe('cli');
    });
  });

  // ==========================================================================
  // Config Management Tests (inherited from BaseProvider)
  // ==========================================================================

  describe('config management', () => {
    it('should get and set config', () => {
      provider.setConfig({ apiKey: 'test-api-key' });

      const config = provider.getConfig();
      expect(config.apiKey).toBe('test-api-key');
    });

    it('should merge config updates', () => {
      provider.setConfig({ apiKey: 'key1' });
      provider.setConfig({ model: 'model1' });

      const config = provider.getConfig();
      expect(config.apiKey).toBe('key1');
      expect(config.model).toBe('model1');
    });
  });

  describe('validateConfig', () => {
    it('should validate config from base class', () => {
      const result = provider.validateConfig();

      expect(result.valid).toBe(true);
      expect(result.errors).toHaveLength(0);
    });
  });

  // ==========================================================================
  // Additional Edge Case Tests
  // ==========================================================================

  describe('extractPromptText edge cases', () => {
    function setupMockedProvider(): OpencodeProvider {
      const mockedProvider = new OpencodeProvider();
      (mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode';
      (mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native';
      return mockedProvider;
    }

    it('should handle empty array prompt', async () => {
      const mockedProvider = setupMockedProvider();

      vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());

      await collectAsyncGenerator(
        mockedProvider.executeQuery({
          prompt: [] as unknown as string,
          cwd: '/tmp',
        })
      );

      const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
      expect(call.stdinData).toBe('');
    });

    it('should handle array prompt with only image blocks (no text)', async () => {
      const mockedProvider = setupMockedProvider();

      vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());

      const imageOnlyPrompt = [
        { type: 'image', source: { type: 'base64', data: 'abc123' } },
        { type: 'image', source: { type: 'base64', data: 'def456' } },
      ];

      await collectAsyncGenerator(
        mockedProvider.executeQuery({
          prompt: imageOnlyPrompt as unknown as string,
          cwd: '/tmp',
        })
      );

      const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
      expect(call.stdinData).toBe('');
    });

    it('should handle array prompt with mixed content types', async () => {
      const mockedProvider = setupMockedProvider();

      vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());

      const mixedPrompt = [
        { type: 'text', text: 'Analyze this image' },
        { type: 'image', source: { type: 'base64', data: 'abc123' } },
        { type: 'text', text: 'And this one' },
        { type: 'image', source: { type: 'base64', data: 'def456' } },
        { type: 'text', text: 'What differences do you see?' },
      ];

      await collectAsyncGenerator(
        mockedProvider.executeQuery({
          prompt: mixedPrompt as unknown as string,
          cwd: '/tmp',
        })
      );

      const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
      expect(call.stdinData).toBe('Analyze this image\nAnd this one\nWhat differences do you see?');
    });

    it('should handle text blocks with empty text property', async () => {
      const mockedProvider = setupMockedProvider();

      vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());

      const promptWithEmptyText = [
        { type: 'text', text: 'Hello' },
        { type: 'text', text: '' },
        { type: 'text', text: 'World' },
      ];

      await collectAsyncGenerator(
        mockedProvider.executeQuery({
          prompt: promptWithEmptyText as unknown as string,
          cwd: '/tmp',
        })
      );

      const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
      // Empty text blocks should be filtered out
      expect(call.stdinData).toBe('Hello\nWorld');
    });
  });

  describe('abort handling', () => {
    function setupMockedProvider(): OpencodeProvider {
      const mockedProvider = new OpencodeProvider();
      (mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode';
      (mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native';
      return mockedProvider;
    }

    it('should pass abortController to subprocess options', async () => {
      const mockedProvider = setupMockedProvider();

      vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());

      const abortController = new AbortController();

      await collectAsyncGenerator(
        mockedProvider.executeQuery({
          prompt: 'Test',
          cwd: '/tmp',
          abortController,
        })
      );

      const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
      expect(call.abortController).toBe(abortController);
    });
  });

  describe('session_id preservation', () => {
    function setupMockedProvider(): OpencodeProvider {
      const mockedProvider = new OpencodeProvider();
      (mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode';
      (mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native';
      return mockedProvider;
    }

    it('should preserve session_id through the full executeQuery flow', async () => {
      const mockedProvider = setupMockedProvider();
      const sessionId = 'test-session-123';

      const mockEvents = [
        { type: 'text', part: { type: 'text', text: 'Hello ' }, sessionID: sessionId },
        {
          type: 'tool_call',
          part: { type: 'tool-call', name: 'Read', args: {}, call_id: 'c1' },
          sessionID: sessionId,
        },
        {
          type: 'tool_result',
          part: { type: 'tool-result', call_id: 'c1', output: 'file content' },
          sessionID: sessionId,
        },
        {
          type: 'step_finish',
          part: { type: 'step-finish', reason: 'stop', result: 'Done' },
          sessionID: sessionId,
        },
      ];

      vi.mocked(spawnJSONLProcess).mockReturnValue(
        (async function* () {
          for (const event of mockEvents) {
            yield event;
          }
        })()
      );

      const results = await collectAsyncGenerator<ProviderMessage>(
        mockedProvider.executeQuery({
          prompt: 'Test',
          model: 'opencode/big-pickle',
          cwd: '/tmp',
        })
      );

      // All emitted messages should have the session_id
      expect(results).toHaveLength(4);
      results.forEach((result) => {
        expect(result.session_id).toBe(sessionId);
      });
    });
  });

  describe('normalizeEvent additional edge cases', () => {
    it('should handle tool_call with empty args object', () => {
      const event = {
        type: 'tool_call',
        part: {
          type: 'tool-call',
          call_id: 'call-123',
          name: 'Glob',
          args: {},
        },
      };

      const result = provider.normalizeEvent(event);

      expect(result?.message?.content[0].type).toBe('tool_use');
      expect(result?.message?.content[0].input).toEqual({});
    });

    it('should handle tool_call with null args', () => {
      const event = {
        type: 'tool_call',
        part: {
          type: 'tool-call',
          call_id: 'call-123',
          name: 'Glob',
          args: null,
        },
      };

      const result = provider.normalizeEvent(event);

      expect(result?.message?.content[0].type).toBe('tool_use');
      expect(result?.message?.content[0].input).toBeNull();
    });

    it('should handle tool_call with complex nested args', () => {
      const event = {
        type: 'tool_call',
        part: {
          type: 'tool-call',
          call_id: 'call-123',
          name: 'Edit',
          args: {
            file_path: '/tmp/test.ts',
            changes: [
              { line: 10, old: 'foo', new: 'bar' },
              { line: 20, old: 'baz', new: 'qux' },
            ],
            options: { replace_all: true },
          },
        },
      };

      const result = provider.normalizeEvent(event);

      expect(result?.message?.content[0].type).toBe('tool_use');
      expect(result?.message?.content[0].input).toEqual({
        file_path: '/tmp/test.ts',
        changes: [
          { line: 10, old: 'foo', new: 'bar' },
          { line: 20, old: 'baz', new: 'qux' },
        ],
        options: { replace_all: true },
      });
    });

    it('should handle tool_result with empty output', () => {
      const event = {
        type: 'tool_result',
        part: {
          type: 'tool-result',
          call_id: 'call-123',
          output: '',
        },
      };

      const result = provider.normalizeEvent(event);

      expect(result?.message?.content[0].type).toBe('tool_result');
      expect(result?.message?.content[0].content).toBe('');
    });

    it('should handle text with whitespace-only text', () => {
      const event = {
        type: 'text',
        part: {
          type: 'text',
          text: '   ',
        },
      };

      const result = provider.normalizeEvent(event);

      // Whitespace should be preserved (not filtered like empty string)
      expect(result).not.toBeNull();
      expect(result?.message?.content[0].text).toBe('   ');
    });

    it('should handle text with newlines', () => {
      const event = {
        type: 'text',
        part: {
          type: 'text',
          text: 'Line 1\nLine 2\nLine 3',
        },
      };

      const result = provider.normalizeEvent(event);

      expect(result?.message?.content[0].text).toBe('Line 1\nLine 2\nLine 3');
    });

    it('should handle step_finish with both result and error (error takes precedence)', () => {
      const event = {
        type: 'step_finish',
        part: {
          type: 'step-finish',
          reason: 'stop',
          result: 'Some result',
          error: 'But also an error',
        },
      };

      const result = provider.normalizeEvent(event);

      expect(result?.type).toBe('error');
      expect(result?.error).toBe('But also an error');
    });
  });

  describe('isInstalled', () => {
    it('should return true when CLI path is set', async () => {
      (provider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode';
      (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'native';

      const result = await provider.isInstalled();

      expect(result).toBe(true);
    });

    it('should return false when CLI path is null', async () => {
      (provider as unknown as { cliPath: string | null }).cliPath = null;
      (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx';

      const result = await provider.isInstalled();

      expect(result).toBe(false);
    });
  });

  describe('model tier validation', () => {
    it('should have exactly one default model', () => {
      const models = provider.getAvailableModels();
      const defaultModels = models.filter((m) => m.default === true);

      expect(defaultModels).toHaveLength(1);
      expect(defaultModels[0].id).toBe('opencode/big-pickle');
    });

    it('should have valid tier values for all models', () => {
      const models = provider.getAvailableModels();
      const validTiers = ['basic', 'standard', 'premium'];

      models.forEach((model) => {
        expect(validTiers).toContain(model.tier);
      });
    });

    it('should have descriptions for all models', () => {
      const models = provider.getAvailableModels();

      models.forEach((model) => {
        expect(model.description).toBeDefined();
        expect(typeof model.description).toBe('string');
        expect(model.description!.length).toBeGreaterThan(0);
      });
    });
  });

  describe('buildCliArgs edge cases', () => {
    it('should handle very long prompts', () => {
      const longPrompt = 'a'.repeat(10000);
      const args = provider.buildCliArgs({
        prompt: longPrompt,
        model: 'opencode/big-pickle',
        cwd: '/tmp',
      });

      // The prompt is NOT in args (it's passed via stdin)
      // Just verify the args structure is correct
      expect(args).toContain('run');
      expect(args).not.toContain('-');
      expect(args.join(' ')).not.toContain(longPrompt);
    });

    it('should handle prompts with special characters', () => {
      const specialPrompt = 'Test $HOME $(rm -rf /) `command` "quotes" \'single\'';
      const args = provider.buildCliArgs({
        prompt: specialPrompt,
        model: 'opencode/big-pickle',
        cwd: '/tmp',
      });

      // Special chars in prompt should not affect args (prompt is via stdin)
      expect(args).toContain('run');
      expect(args).not.toContain('-');
    });

    it('should handle cwd with spaces', () => {
      const args = provider.buildCliArgs({
        prompt: 'Test',
        model: 'opencode/big-pickle',
        cwd: '/tmp/path with spaces/project',
      });

      // cwd is set at subprocess level, not via CLI args
      expect(args).not.toContain('-c');
      expect(args).not.toContain('/tmp/path with spaces/project');
    });

    it('should handle model with unusual characters', () => {
      const args = provider.buildCliArgs({
        prompt: 'Test',
        model: 'opencode-provider/model-v1.2.3-beta',
        cwd: '/tmp',
      });

      const modelIndex = args.indexOf('--model');
      expect(args[modelIndex + 1]).toBe('provider/model-v1.2.3-beta');
    });
  });
});
