import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { resolveGatewayModel } from '../local-gateway.js';
import { prefixBoocodeLocalModels, clearProviderSnapshotCache, getProviderSnapshot } from '../provider-snapshot.js';
import { loadLlamaProviders } from '../llama-providers.js';
import { loadProviderConfig } from '../provider-config-registry.js';

vi.mock('../acp-probe.js', () => ({
  probeAcpProvider: vi.fn(),
}));
import { probeAcpProvider } from '../acp-probe.js';
const mockProbe = vi.mocked(probeAcpProvider);

/** Load a providers fixture into the in-memory registry. */
function loadProvidersFixture(providers: Array<{ id: string; label: string; baseUrl: string; kind?: string }>): void {
  const file = {
    defaultProvider: providers[0]?.id ?? 'llama-swap',
    providers,
  };
  const path = join(tmpdir(), `llama-providers-w7-${Date.now()}.json`);
  writeFileSync(path, JSON.stringify(file), 'utf8');
  loadLlamaProviders(path, 'http://localhost:8080');
}

function mockSql(agents: Array<{
  name: string;
  install_path: string | null;
  supports_acp: boolean;
  models: Array<{ id: string; label: string }> | null;
  label: string | null;
  transport: string | null;
  last_probed_at?: string | null;
}>) {
  return vi.fn((strings: TemplateStringsArray) => {
    const query = strings.join('');
    if (query.includes('FROM available_agents')) {
      return Promise.resolve(agents);
    }
    if (query.includes('UPDATE available_agents')) {
      return Promise.resolve([]);
    }
    return Promise.resolve([]);
  }) as unknown as import('../db.js').Sql;
}

// --- Gateway model-id parsing tests ---

describe('resolveGatewayModel', () => {
  beforeEach(() => {
    loadProvidersFixture([
      { id: 'sam-desktop', label: 'Sam Desktop', baseUrl: 'http://100.101.41.16:8401' },
      { id: 'embedding', label: 'Embedding', baseUrl: 'http://100.90.172.55:8411' },
    ]);
  });

  it('resolves composite "provider/model" to the correct baseUrl', () => {
    const result = resolveGatewayModel('sam-desktop/qwen3.6-35b');
    expect(result).toEqual({
      baseUrl: 'http://100.101.41.16:8401',
      wireModelId: 'qwen3.6-35b',
    });
  });

  it('resolves a different provider to its own baseUrl', () => {
    const result = resolveGatewayModel('embedding/gemma-4-12b');
    expect(result).toEqual({
      baseUrl: 'http://100.90.172.55:8411',
      wireModelId: 'gemma-4-12b',
    });
  });

  it('returns error for unknown provider', () => {
    const result = resolveGatewayModel('nonexistent/model');
    expect(result).toHaveProperty('error');
    expect((result as { error: string }).error).toContain('unknown provider');
  });

  it('bare model resolves to default provider', () => {
    const result = resolveGatewayModel('qwen3.6-35b');
    expect(result).toEqual({
      baseUrl: 'http://100.101.41.16:8401',
      wireModelId: 'qwen3.6-35b',
    });
  });

  it('two providers serving the SAME wire model name hit different baseUrls', () => {
    const r1 = resolveGatewayModel('sam-desktop/qwen3.6-35b');
    const r2 = resolveGatewayModel('embedding/qwen3.6-35b');
    expect(r1).toHaveProperty('baseUrl', 'http://100.101.41.16:8401');
    expect(r2).toHaveProperty('baseUrl', 'http://100.90.172.55:8411');
    expect((r1 as { wireModelId: string }).wireModelId).toBe('qwen3.6-35b');
    expect((r2 as { wireModelId: string }).wireModelId).toBe('qwen3.6-35b');
  });
});

// --- prefixBoocodeLocalModels ---

describe('prefixBoocodeLocalModels', () => {
  it('wraps composite ids with boocode-local prefix', () => {
    const result = prefixBoocodeLocalModels([
      { id: 'sam-desktop/qwen3.6-35b', label: 'Qwen' },
      { id: 'embedding/gemma-4-12b', label: 'Gemma' },
    ]);
    expect(result.map((m) => m.id)).toEqual([
      'boocode-local/sam-desktop/qwen3.6-35b',
      'boocode-local/embedding/gemma-4-12b',
    ]);
  });

  it('leaves already-prefixed ids unchanged', () => {
    const result = prefixBoocodeLocalModels([
      { id: 'boocode-local/sam-desktop/qwen3.6-35b', label: 'Qwen' },
    ]);
    expect(result[0].id).toBe('boocode-local/sam-desktop/qwen3.6-35b');
  });

  it('preserves label and other fields', () => {
    const result = prefixBoocodeLocalModels([
      { id: 'sam-desktop/qwen3.6-35b', label: 'Qwen 3.6 35B', isDefault: true },
    ]);
    expect(result[0]).toEqual({
      id: 'boocode-local/sam-desktop/qwen3.6-35b',
      label: 'Qwen 3.6 35B',
      isDefault: true,
    });
  });
});

// --- parseModel inner-slash preservation ---

describe('gateway model id parsing preserves inner slashes', () => {
  beforeEach(() => {
    loadProvidersFixture([
      { id: 'sam-desktop', label: 'Sam Desktop', baseUrl: 'http://100.101.41.16:8401' },
    ]);
  });

  it('parses "sam-desktop/qwen3.6-35b-a3b-mxfp4" preserving the full wire id', () => {
    const result = resolveGatewayModel('sam-desktop/qwen3.6-35b-a3b-mxfp4');
    expect(result).toHaveProperty('wireModelId', 'qwen3.6-35b-a3b-mxfp4');
  });

  it('parses model ids with dots and hyphens', () => {
    const result = resolveGatewayModel('sam-desktop/deepseek-r1-0528');
    expect(result).toHaveProperty('wireModelId', 'deepseek-r1-0528');
  });
});

// --- Snapshot advertising shape (integration) ---

describe('provider snapshot opencode entry uses boocode-local prefix', () => {
  beforeEach(() => {
    clearProviderSnapshotCache();
    loadProviderConfig('/nonexistent-coder-providers.json');
    vi.restoreAllMocks();
    vi.stubGlobal(
      'fetch',
      vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          data: [{ id: 'local-model' }, { id: 'qwen3.6-35b' }],
        }),
      }),
    );
    mockProbe.mockResolvedValue({
      ok: true,
      models: [],
      modes: [],
      defaultModeId: null,
      commands: [],
    });
  });

  it('opencode snapshot entry has boocode-local prefixed model ids', async () => {
    loadProvidersFixture([
      { id: 'sam-desktop', label: 'Sam Desktop', baseUrl: 'http://100.101.41.16:8401' },
    ]);

    const sql = mockSql([
      {
        name: 'opencode',
        install_path: '/usr/bin/opencode',
        supports_acp: true,
        models: null,
        label: 'OpenCode',
        transport: 'acp',
        last_probed_at: null,
      },
    ]);

    const config = {
      LLAMA_SWAP_URL: 'http://llama-swap.test',
      PROVIDER_PROBE_TTL_MS: 86_400_000,
      DEFAULT_MODEL: 'qwen3.6-35b',
    } as import('../config.js').Config;

    const entries = await getProviderSnapshot(sql, config, '/tmp/test', true);
    const opencode = entries.find((e) => e.name === 'opencode');

    expect(opencode).toBeDefined();
    // W7: all model ids start with "boocode-local/" and never "llama-swap/".
    for (const m of opencode!.models) {
      expect(m.id).toMatch(/^boocode-local\//);
      expect(m.id).not.toMatch(/^llama-swap\//);
    }
  });
});

// --- Gateway HTTP proxy tests (W7 audit M3) ---

describe('local gateway HTTP proxy', () => {
  let app: import('fastify').FastifyInstance;
  const fetchMock = vi.fn();

  beforeEach(async () => {
    loadProvidersFixture([
      { id: 'sam-desktop', label: 'Sam Desktop', baseUrl: 'http://machine-a.test:8401' },
      { id: 'laptop', label: 'Laptop', baseUrl: 'http://machine-b.test:8401' },
    ]);
    vi.stubGlobal('fetch', fetchMock);
    fetchMock.mockReset();
    const { default: Fastify } = await import('fastify');
    const { registerLocalGatewayRoutes } = await import('../local-gateway.js');
    app = Fastify({ logger: false });
    registerLocalGatewayRoutes(app);
    await app.ready();
  });

  afterEach(async () => {
    vi.unstubAllGlobals();
    await app.close();
  });

  it('proxies non-streaming requests to the right provider with the bare wire id', async () => {
    fetchMock.mockResolvedValue(
      new Response(JSON.stringify({ id: 'cmpl-1', model: 'qwen3.6-35b' }), {
        status: 200,
        headers: { 'content-type': 'application/json' },
      }),
    );
    const res = await app.inject({
      method: 'POST',
      url: '/v1/chat/completions',
      payload: { model: 'sam-desktop/qwen3.6-35b', messages: [] },
    });
    expect(res.statusCode).toBe(200);
    expect(res.json()).toMatchObject({ id: 'cmpl-1' });
    expect(fetchMock).toHaveBeenCalledTimes(1);
    const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
    expect(url).toBe('http://machine-a.test:8401/v1/chat/completions');
    expect(JSON.parse(init.body as string).model).toBe('qwen3.6-35b');
  });

  it('routes duplicate wire model names to different machines by provider prefix', async () => {
    fetchMock.mockResolvedValue(
      new Response(JSON.stringify({ ok: true }), {
        status: 200,
        headers: { 'content-type': 'application/json' },
      }),
    );
    await app.inject({
      method: 'POST',
      url: '/v1/chat/completions',
      payload: { model: 'sam-desktop/qwen3.6-35b', messages: [] },
    });
    await app.inject({
      method: 'POST',
      url: '/v1/chat/completions',
      payload: { model: 'laptop/qwen3.6-35b', messages: [] },
    });
    const urls = fetchMock.mock.calls.map((c) => c[0] as string);
    expect(urls).toEqual([
      'http://machine-a.test:8401/v1/chat/completions',
      'http://machine-b.test:8401/v1/chat/completions',
    ]);
  });

  it('returns 400 for an unknown provider without calling upstream', async () => {
    const res = await app.inject({
      method: 'POST',
      url: '/v1/chat/completions',
      payload: { model: 'nonexistent/some-model', messages: [] },
    });
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toContain('unknown provider');
    expect(fetchMock).not.toHaveBeenCalled();
  });

  it('returns 400 when the model field is missing', async () => {
    const res = await app.inject({
      method: 'POST',
      url: '/v1/chat/completions',
      payload: { messages: [] },
    });
    expect(res.statusCode).toBe(400);
    expect(fetchMock).not.toHaveBeenCalled();
  });

  it('returns an OpenAI-shaped 502 error when upstream replies non-JSON', async () => {
    fetchMock.mockResolvedValue(
      new Response('<html>gateway error</html>', {
        status: 200,
        headers: { 'content-type': 'text/html' },
      }),
    );
    const res = await app.inject({
      method: 'POST',
      url: '/v1/chat/completions',
      payload: { model: 'sam-desktop/qwen3.6-35b', messages: [] },
    });
    expect(res.statusCode).toBe(502);
    expect(res.json().error.message).toContain('non-JSON');
  });

  it('relays streaming responses chunk-for-chunk with the upstream status', async () => {
    const chunks = ['data: {"a":1}\n\n', 'data: {"a":2}\n\n', 'data: [DONE]\n\n'];
    const stream = new ReadableStream<Uint8Array>({
      start(controller) {
        for (const c of chunks) controller.enqueue(new TextEncoder().encode(c));
        controller.close();
      },
    });
    fetchMock.mockResolvedValue(
      new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } }),
    );
    const res = await app.inject({
      method: 'POST',
      url: '/v1/chat/completions',
      payload: { model: 'laptop/qwen3.6-35b', messages: [], stream: true },
    });
    expect(res.statusCode).toBe(200);
    expect(res.headers['content-type']).toBe('text/event-stream');
    expect(res.body).toBe(chunks.join(''));
  });

  it('forwards inbound X-Boo-Source header to upstream', async () => {
    fetchMock.mockResolvedValue(
      new Response(JSON.stringify({ ok: true }), {
        status: 200,
        headers: { 'content-type': 'application/json' },
      }),
    );
    await app.inject({
      method: 'POST',
      url: '/v1/chat/completions',
      payload: { model: 'sam-desktop/qwen3.6-35b', messages: [] },
      headers: { 'x-boo-source': 'arena' },
    });
    expect(fetchMock).toHaveBeenCalledTimes(1);
    const callHeaders = (fetchMock.mock.calls[0] as [string, RequestInit])[1]?.headers as Record<string, string>;
    expect(callHeaders['X-Boo-Source']).toBe('arena');
  });

  it('defaults X-Boo-Source to boocoder when not present', async () => {
    fetchMock.mockResolvedValue(
      new Response(JSON.stringify({ ok: true }), {
        status: 200,
        headers: { 'content-type': 'application/json' },
      }),
    );
    await app.inject({
      method: 'POST',
      url: '/v1/chat/completions',
      payload: { model: 'sam-desktop/qwen3.6-35b', messages: [] },
    });
    expect(fetchMock).toHaveBeenCalledTimes(1);
    const callHeaders = (fetchMock.mock.calls[0] as [string, RequestInit])[1]?.headers as Record<string, string>;
    expect(callHeaders['X-Boo-Source']).toBe('boocoder');
  });
});

// --- opencode config sync (W7) ---
// syncOpencodeConfig reads/writes ~/.config/opencode/opencode.jsonc via
// node:os.homedir(), making it hard to unit-test without module-level mocking.
// Behaviour is verified via integration: restart boocoder → check config.
