import { describe, it, expect, vi, beforeEach } from 'vitest';
import { writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
  mergeModels,
  prefixLlamaSwapModels,
  clearProviderSnapshotCache,
  getProviderSnapshot,
  peekSnapshotEntry,
} from '../provider-snapshot.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);

/** Write a temp coder-providers.json and point the resolved registry at it. */
function loadConfigFixture(providers: Record<string, unknown>): void {
  const path = join(tmpdir(), `coder-providers-test-${providers ? Object.keys(providers).join('-') || 'empty' : 'empty'}.json`);
  writeFileSync(path, JSON.stringify({ providers }), 'utf8');
  loadProviderConfig(path);
}

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;
}

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

describe('prefixLlamaSwapModels', () => {
  it('prefixes bare ids', () => {
    expect(prefixLlamaSwapModels([{ id: 'qwen3', label: 'qwen3' }])).toEqual([
      { id: 'llama-swap/qwen3', label: 'qwen3' },
    ]);
  });

  it('leaves already-prefixed ids unchanged', () => {
    expect(prefixLlamaSwapModels([{ id: 'llama-swap/qwen3', label: 'qwen3' }])).toEqual([
      { id: 'llama-swap/qwen3', label: 'qwen3' },
    ]);
  });
});

describe('mergeModels', () => {
  it('dedupes by id preserving first occurrence', () => {
    const merged = mergeModels(
      [{ id: 'a', label: 'A' }],
      [{ id: 'a', label: 'A2' }, { id: 'b', label: 'B' }],
    );
    expect(merged).toEqual([
      { id: 'a', label: 'A' },
      { id: 'b', label: 'B' },
    ]);
  });
});

describe('getProviderSnapshot', () => {
  beforeEach(() => {
    clearProviderSnapshotCache();
    // Reset the resolved registry to built-ins-only (missing path → {} config).
    loadProviderConfig('/nonexistent-coder-providers.json');
    vi.restoreAllMocks();
    vi.stubGlobal(
      'fetch',
      vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          data: [{ id: 'local-model' }, { id: 'existing' }],
        }),
      }),
    );
  });

  it('merges opencode ACP models with boocode-local prefixed registry models', async () => {
    mockProbe.mockResolvedValue({
      ok: true,
      models: [{ id: 'opencode/big-pickle', label: 'Big Pickle', isDefault: true }],
      modes: [{ id: 'build', label: 'Build' }],
      defaultModeId: 'build',
      commands: [{ name: 'custom', description: 'From ACP probe' }],
    });

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

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

    // W7: registry models are prefixed with boocode-local/ (D-6), not llama-swap/.
    expect(opencode?.models.map((m) => m.id)).toEqual([
      'opencode/big-pickle',
      'boocode-local/llama-swap/local-model',
      'boocode-local/llama-swap/existing',
    ]);
    expect(opencode?.commands.some((c) => c.name === 'help')).toBe(true);
    expect(opencode?.commands.some((c) => c.name === 'custom')).toBe(true);
  });

  it('combines qwen-shaped probe and settings model lists via mergeModels', () => {
    const merged = mergeModels(
      [{ id: 'qwen-probed', label: 'Qwen Probed' }],
      [{ id: 'from-settings', label: 'from-settings' }],
    );
    expect(merged.map((m) => m.id)).toEqual(['qwen-probed', 'from-settings']);
  });

  it('returns cached entries on second call within TTL', async () => {
    mockProbe.mockResolvedValue({
      ok: true,
      models: [{ id: 'm1', label: 'M1' }],
      modes: [],
      defaultModeId: null,
      commands: [],
    });

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

    await getProviderSnapshot(sql, config, '/tmp/cwd', true);
    await getProviderSnapshot(sql, config, '/tmp/cwd', false);

    expect(mockProbe).toHaveBeenCalledTimes(1);
  });

  it('attaches claude thinking options', async () => {
    const sql = mockSql([
      {
        name: 'claude',
        install_path: '/usr/bin/claude',
        supports_acp: false,
        models: [{ id: 'claude-sonnet', label: 'Sonnet' }],
        label: 'Claude Code',
        transport: 'pty',
      },
    ]);

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

    expect(claude?.models[0]?.thinkingOptions?.length).toBeGreaterThan(0);
    expect(claude?.modes.length).toBeGreaterThan(0);
    expect(claude?.commands.some((c) => c.name === 'help')).toBe(true);
  });

  it('disabled provider → unavailable + enabled:false, WITHOUT spawning a probe', async () => {
    loadConfigFixture({ goose: { enabled: false } });
    mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] });

    const sql = mockSql([
      {
        name: 'goose',
        install_path: '/usr/bin/goose',
        supports_acp: true,
        models: [{ id: 'g1', label: 'G1' }],
        label: 'Goose',
        transport: 'acp',
        last_probed_at: new Date().toISOString(),
      },
    ]);

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

    expect(goose?.status).toBe('unavailable');
    expect(goose?.enabled).toBe(false);
    expect(goose?.installed).toBe(false);
    expect(mockProbe).not.toHaveBeenCalled();
  });

  it('uninstalled provider → unavailable + enabled:true + installed:false', async () => {
    loadConfigFixture({});
    mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] });

    const sql = mockSql([]); // nothing probed/installed
    const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
    const opencode = entries.find((e) => e.name === 'opencode');

    expect(opencode?.status).toBe('unavailable');
    expect(opencode?.enabled).toBe(true);
    expect(opencode?.installed).toBe(false);
    expect(mockProbe).not.toHaveBeenCalled();
  });

  it('fresh DB within TTL → tier-2 cold probe SKIPPED (serves DB models)', async () => {
    loadConfigFixture({});
    // If this were wrongly called, cached-goose would be replaced and the
    // not.toHaveBeenCalled assertion would fail.
    mockProbe.mockResolvedValue({
      ok: true,
      models: [{ id: 'SHOULD-NOT-APPEAR', label: 'nope' }],
      modes: [],
      defaultModeId: null,
      commands: [],
    });

    const sql = mockSql([
      {
        name: 'goose',
        install_path: '/usr/bin/goose',
        supports_acp: true,
        models: [{ id: 'cached-goose', label: 'Cached Goose' }],
        label: 'Goose',
        transport: 'acp',
        last_probed_at: new Date().toISOString(), // fresh
      },
    ]);

    // force=false → cache-miss returns loading; second call joins the build / cache.
    await getProviderSnapshot(sql, config, '/tmp/cwd', false);
    const entries = await getProviderSnapshot(sql, config, '/tmp/cwd', false);
    const goose = entries.find((e) => e.name === 'goose');

    expect(goose?.status).toBe('ready');
    expect(goose?.installed).toBe(true);
    expect(goose?.models.map((m) => m.id)).toContain('cached-goose');
    expect(goose?.models.map((m) => m.id)).not.toContain('SHOULD-NOT-APPEAR');
    expect(mockProbe).not.toHaveBeenCalled();
  });

  it('force refresh → tier-2 cold probe RUNS even when DB is fresh', async () => {
    loadConfigFixture({});
    mockProbe.mockResolvedValue({
      ok: true,
      models: [{ id: 'fresh-probe', label: 'Fresh' }],
      modes: [],
      defaultModeId: null,
      commands: [],
    });

    const sql = mockSql([
      {
        name: 'goose',
        install_path: '/usr/bin/goose',
        supports_acp: true,
        models: [{ id: 'cached-goose', label: 'Cached' }],
        label: 'Goose',
        transport: 'acp',
        last_probed_at: new Date().toISOString(), // fresh, but force overrides
      },
    ]);

    await getProviderSnapshot(sql, config, '/tmp/cwd', true);
    expect(mockProbe).toHaveBeenCalled();
  });

  it('native boocode → ready, enabled, installed', async () => {
    loadConfigFixture({});
    const sql = mockSql([]);
    const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
    const boocode = entries.find((e) => e.name === 'boocode');

    expect(boocode?.status).toBe('ready');
    expect(boocode?.enabled).toBe(true);
    expect(boocode?.installed).toBe(true);
  });

  it('config models REPLACE the claude static list; additionalModels merge (+ thinking)', async () => {
    loadConfigFixture({
      claude: {
        models: [{ id: 'claude-opus-4-8', label: 'Opus 4.8' }],
        additionalModels: [{ id: 'sonnet', label: 'Sonnet (latest)' }],
      },
    });

    const sql = mockSql([
      {
        name: 'claude',
        install_path: '/usr/bin/claude',
        supports_acp: false,
        models: [{ id: 'old-static', label: 'Old' }],
        label: 'Claude Code',
        transport: 'pty',
        last_probed_at: new Date().toISOString(),
      },
    ]);

    const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
    const claude = entries.find((e) => e.name === 'claude');
    const ids = claude!.models.map((m) => m.id);

    expect(ids).toContain('claude-opus-4-8'); // config models replaced the DB/static list
    expect(ids).toContain('sonnet'); // additionalModels merged on top
    expect(ids).not.toContain('old-static'); // replaced, not appended
    // thinking options still attach to the config-provided models
    expect(claude!.models.find((m) => m.id === 'claude-opus-4-8')?.thinkingOptions?.length).toBeGreaterThan(0);
  });

  it('peekSnapshotEntry returns a cached entry (read-only) and undefined when cold/unknown', async () => {
    loadConfigFixture({});
    // Cold cache → undefined (no build triggered).
    expect(peekSnapshotEntry('boocode', '/tmp/peek')).toBeUndefined();

    const sql = mockSql([]);
    await getProviderSnapshot(sql, config, '/tmp/peek', true);

    expect(peekSnapshotEntry('boocode', '/tmp/peek')?.name).toBe('boocode');
    expect(peekSnapshotEntry('does-not-exist', '/tmp/peek')).toBeUndefined();
  });

  it('2.7 warm cache: a second snapshot within the warm window spawns ZERO probes', async () => {
    loadConfigFixture({});
    mockProbe.mockResolvedValue({
      ok: true,
      models: [{ id: 'm1', label: 'M1' }],
      modes: [],
      defaultModeId: null,
      commands: [],
    });

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

    await getProviderSnapshot(sql, config, '/tmp/cwd', true); // cold populate
    const probeCallsAfterFirst = mockProbe.mock.calls.length;
    await getProviderSnapshot(sql, config, '/tmp/cwd', false); // warm read
    const probeCallsAfterSecond = mockProbe.mock.calls.length;

    // Success criterion: second snapshot is served from cache with no ACP spawns.
    expect(probeCallsAfterSecond - probeCallsAfterFirst).toBe(0);
  });
});
