import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RoutingEngineService } from './routing-engine.service.js';
import type { RoutingRule, TaskClassification } from './routing.types.js';

// ─── Helpers ─────────────────────────────────────────────────────────────────

function makeRule(
  overrides: Partial<RoutingRule> &
    Pick<RoutingRule, 'name' | 'priority' | 'conditions' | 'action'>,
): RoutingRule {
  return {
    id: overrides.id ?? crypto.randomUUID(),
    scope: 'system',
    enabled: true,
    ...overrides,
  };
}

function makeClassification(overrides: Partial<TaskClassification> = {}): TaskClassification {
  return {
    taskType: 'conversation',
    complexity: 'simple',
    domain: 'general',
    requiredCapabilities: [],
    ...overrides,
  };
}

/** Build a minimal RoutingEngineService with mocked DB and ProviderService. */
function makeService(
  rules: RoutingRule[] = [],
  healthMap: Record<string, { status: string }> = {},
): RoutingEngineService {
  const mockDb = {
    select: vi.fn().mockReturnValue({
      from: vi.fn().mockReturnValue({
        where: vi.fn().mockReturnValue({
          orderBy: vi.fn().mockResolvedValue(
            rules.map((r) => ({
              id: r.id,
              name: r.name,
              priority: r.priority,
              scope: r.scope,
              userId: r.userId ?? null,
              conditions: r.conditions,
              action: r.action,
              enabled: r.enabled,
              createdAt: new Date(),
              updatedAt: new Date(),
            })),
          ),
        }),
      }),
    }),
  };

  const mockProviderService = {
    healthCheckAll: vi.fn().mockResolvedValue(healthMap),
  };

  // Inject mocked dependencies directly (bypass NestJS DI for unit tests)
  const service = new (RoutingEngineService as unknown as new (
    db: unknown,
    ps: unknown,
  ) => RoutingEngineService)(mockDb, mockProviderService);

  return service;
}

// ─── matchConditions ──────────────────────────────────────────────────────────

describe('RoutingEngineService.matchConditions', () => {
  let service: RoutingEngineService;

  beforeEach(() => {
    service = makeService();
  });

  it('returns true for empty conditions array (catch-all rule)', () => {
    const rule = makeRule({
      name: 'fallback',
      priority: 99,
      conditions: [],
      action: { provider: 'anthropic', model: 'claude-sonnet-4-6' },
    });
    expect(service.matchConditions(rule, makeClassification())).toBe(true);
  });

  it('matches eq operator on scalar field', () => {
    const rule = makeRule({
      name: 'coding',
      priority: 1,
      conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
      action: { provider: 'anthropic', model: 'claude-opus-4-6' },
    });
    expect(service.matchConditions(rule, makeClassification({ taskType: 'coding' }))).toBe(true);
    expect(service.matchConditions(rule, makeClassification({ taskType: 'conversation' }))).toBe(
      false,
    );
  });

  it('matches in operator: field value is in the condition array', () => {
    const rule = makeRule({
      name: 'simple or moderate',
      priority: 2,
      conditions: [{ field: 'complexity', operator: 'in', value: ['simple', 'moderate'] }],
      action: { provider: 'anthropic', model: 'claude-haiku-4-5' },
    });
    expect(service.matchConditions(rule, makeClassification({ complexity: 'simple' }))).toBe(true);
    expect(service.matchConditions(rule, makeClassification({ complexity: 'moderate' }))).toBe(
      true,
    );
    expect(service.matchConditions(rule, makeClassification({ complexity: 'complex' }))).toBe(
      false,
    );
  });

  it('matches includes operator: field array includes the condition value', () => {
    const rule = makeRule({
      name: 'reasoning required',
      priority: 3,
      conditions: [{ field: 'requiredCapabilities', operator: 'includes', value: 'reasoning' }],
      action: { provider: 'anthropic', model: 'claude-opus-4-6' },
    });
    expect(
      service.matchConditions(rule, makeClassification({ requiredCapabilities: ['reasoning'] })),
    ).toBe(true);
    expect(
      service.matchConditions(
        rule,
        makeClassification({ requiredCapabilities: ['tools', 'reasoning'] }),
      ),
    ).toBe(true);
    expect(
      service.matchConditions(rule, makeClassification({ requiredCapabilities: ['tools'] })),
    ).toBe(false);
    expect(service.matchConditions(rule, makeClassification({ requiredCapabilities: [] }))).toBe(
      false,
    );
  });

  it('requires ALL conditions to match (AND logic)', () => {
    const rule = makeRule({
      name: 'complex coding',
      priority: 1,
      conditions: [
        { field: 'taskType', operator: 'eq', value: 'coding' },
        { field: 'complexity', operator: 'eq', value: 'complex' },
      ],
      action: { provider: 'anthropic', model: 'claude-opus-4-6' },
    });

    // Both match
    expect(
      service.matchConditions(
        rule,
        makeClassification({ taskType: 'coding', complexity: 'complex' }),
      ),
    ).toBe(true);

    // Only one matches
    expect(
      service.matchConditions(
        rule,
        makeClassification({ taskType: 'coding', complexity: 'simple' }),
      ),
    ).toBe(false);

    // Neither matches
    expect(
      service.matchConditions(
        rule,
        makeClassification({ taskType: 'conversation', complexity: 'simple' }),
      ),
    ).toBe(false);
  });

  it('returns false for eq when condition value is an array (type mismatch)', () => {
    const rule = makeRule({
      name: 'bad eq',
      priority: 1,
      conditions: [{ field: 'taskType', operator: 'eq', value: ['coding', 'research'] }],
      action: { provider: 'anthropic', model: 'claude-sonnet-4-6' },
    });
    expect(service.matchConditions(rule, makeClassification({ taskType: 'coding' }))).toBe(false);
  });

  it('returns false for includes when field is not an array', () => {
    const rule = makeRule({
      name: 'bad includes',
      priority: 1,
      conditions: [{ field: 'taskType', operator: 'includes', value: 'coding' }],
      action: { provider: 'anthropic', model: 'claude-sonnet-4-6' },
    });
    // taskType is a string, not an array — should be false
    expect(service.matchConditions(rule, makeClassification({ taskType: 'coding' }))).toBe(false);
  });
});

// ─── resolve — priority ordering ─────────────────────────────────────────────

describe('RoutingEngineService.resolve — priority ordering', () => {
  it('selects the highest-priority matching rule', async () => {
    // Rules are supplied in priority-ascending order, as the DB would return them.
    const rules = [
      makeRule({
        name: 'high priority',
        priority: 1,
        conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
        action: { provider: 'anthropic', model: 'claude-opus-4-6' },
      }),
      makeRule({
        name: 'low priority',
        priority: 10,
        conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
        action: { provider: 'openai', model: 'gpt-4o' },
      }),
    ];

    const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } });

    const decision = await service.resolve('implement a function');
    expect(decision.ruleName).toBe('high priority');
    expect(decision.provider).toBe('anthropic');
    expect(decision.model).toBe('claude-opus-4-6');
  });

  it('skips non-matching rules and picks first match', async () => {
    const rules = [
      makeRule({
        name: 'research rule',
        priority: 1,
        conditions: [{ field: 'taskType', operator: 'eq', value: 'research' }],
        action: { provider: 'openai', model: 'gpt-4o' },
      }),
      makeRule({
        name: 'coding rule',
        priority: 2,
        conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
        action: { provider: 'anthropic', model: 'claude-sonnet-4-6' },
      }),
    ];

    const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } });

    const decision = await service.resolve('implement a function');
    expect(decision.ruleName).toBe('coding rule');
    expect(decision.provider).toBe('anthropic');
  });
});

// ─── resolve — unhealthy provider fallback ────────────────────────────────────

describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
  it('skips matched rule when provider is unhealthy, tries next rule', async () => {
    const rules = [
      makeRule({
        name: 'primary rule',
        priority: 1,
        conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
        action: { provider: 'anthropic', model: 'claude-opus-4-6' },
      }),
      makeRule({
        name: 'secondary rule',
        priority: 2,
        conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
        action: { provider: 'openai', model: 'gpt-4o' },
      }),
    ];

    const service = makeService(rules, {
      anthropic: { status: 'down' }, // primary is unhealthy
      openai: { status: 'up' },
    });

    const decision = await service.resolve('implement a function');
    expect(decision.ruleName).toBe('secondary rule');
    expect(decision.provider).toBe('openai');
  });

  it('falls back to Sonnet when all rules have unhealthy providers', async () => {
    // Override the rule's provider to something unhealthy but keep anthropic up for fallback
    const unhealthyRules = [
      makeRule({
        name: 'only rule',
        priority: 1,
        conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
        action: { provider: 'openai', model: 'gpt-4o' }, // openai is unhealthy
      }),
    ];

    const service2 = makeService(unhealthyRules, {
      anthropic: { status: 'up' },
      openai: { status: 'down' },
    });

    const decision = await service2.resolve('implement a function');
    // Should fall through to Sonnet fallback on anthropic
    expect(decision.provider).toBe('anthropic');
    expect(decision.model).toBe('claude-sonnet-4-6');
    expect(decision.ruleName).toBe('fallback');
  });

  it('falls back to Haiku when Sonnet provider is also down', async () => {
    const rules: RoutingRule[] = []; // no rules

    const service = makeService(rules, {
      anthropic: { status: 'down' }, // Sonnet is on anthropic — down
      ollama: { status: 'up' }, // Haiku is also on anthropic — use Ollama as next
    });

    const decision = await service.resolve('hello there');
    // Sonnet (anthropic) is down, Haiku (anthropic) is down, Ollama is up
    expect(decision.provider).toBe('ollama');
    expect(decision.model).toBe('llama3.2');
    expect(decision.ruleName).toBe('fallback');
  });

  it('uses last resort (Sonnet) when all fallback providers are unhealthy', async () => {
    const rules: RoutingRule[] = [];

    const service = makeService(rules, {
      anthropic: { status: 'down' },
      ollama: { status: 'down' },
    });

    const decision = await service.resolve('hello');
    // All unhealthy — still returns first fallback entry as last resort
    expect(decision.provider).toBe('anthropic');
    expect(decision.model).toBe('claude-sonnet-4-6');
    expect(decision.ruleName).toBe('fallback');
  });
});

// ─── resolve — empty conditions (catch-all rule) ──────────────────────────────

describe('RoutingEngineService.resolve — empty conditions (fallback rule)', () => {
  it('matches catch-all rule for any message', async () => {
    const rules = [
      makeRule({
        name: 'catch-all',
        priority: 99,
        conditions: [],
        action: { provider: 'anthropic', model: 'claude-sonnet-4-6' },
      }),
    ];

    const service = makeService(rules, { anthropic: { status: 'up' } });

    const decision = await service.resolve('completely unrelated message xyz');
    expect(decision.ruleName).toBe('catch-all');
    expect(decision.provider).toBe('anthropic');
    expect(decision.model).toBe('claude-sonnet-4-6');
  });

  it('catch-all is overridden by a higher-priority specific rule', async () => {
    const rules = [
      makeRule({
        name: 'specific coding rule',
        priority: 1,
        conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
        action: { provider: 'anthropic', model: 'claude-opus-4-6' },
      }),
      makeRule({
        name: 'catch-all',
        priority: 99,
        conditions: [],
        action: { provider: 'anthropic', model: 'claude-haiku-4-5' },
      }),
    ];

    const service = makeService(rules, { anthropic: { status: 'up' } });

    const codingDecision = await service.resolve('implement a function');
    expect(codingDecision.ruleName).toBe('specific coding rule');
    expect(codingDecision.model).toBe('claude-opus-4-6');

    const conversationDecision = await service.resolve('hello how are you');
    expect(conversationDecision.ruleName).toBe('catch-all');
    expect(conversationDecision.model).toBe('claude-haiku-4-5');
  });
});

// ─── resolve — disabled rules ─────────────────────────────────────────────────

describe('RoutingEngineService.resolve — disabled rules', () => {
  it('skips disabled rules', async () => {
    const rules = [
      makeRule({
        name: 'disabled rule',
        priority: 1,
        enabled: false,
        conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
        action: { provider: 'anthropic', model: 'claude-opus-4-6' },
      }),
      makeRule({
        name: 'enabled fallback',
        priority: 99,
        conditions: [],
        action: { provider: 'anthropic', model: 'claude-sonnet-4-6' },
      }),
    ];

    const service = makeService(rules, { anthropic: { status: 'up' } });

    const decision = await service.resolve('implement a function');
    expect(decision.ruleName).toBe('enabled fallback');
    expect(decision.model).toBe('claude-sonnet-4-6');
  });
});

// ─── resolve — pre-fetched health map ────────────────────────────────────────

describe('RoutingEngineService.resolve — availableProviders override', () => {
  it('uses the provided health map instead of calling healthCheckAll', async () => {
    const rules = [
      makeRule({
        name: 'coding rule',
        priority: 1,
        conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
        action: { provider: 'anthropic', model: 'claude-opus-4-6' },
      }),
    ];

    const mockHealthCheckAll = vi.fn().mockResolvedValue({});
    const mockDb = {
      select: vi.fn().mockReturnValue({
        from: vi.fn().mockReturnValue({
          where: vi.fn().mockReturnValue({
            orderBy: vi.fn().mockResolvedValue(
              rules.map((r) => ({
                id: r.id,
                name: r.name,
                priority: r.priority,
                scope: r.scope,
                userId: r.userId ?? null,
                conditions: r.conditions,
                action: r.action,
                enabled: r.enabled,
                createdAt: new Date(),
                updatedAt: new Date(),
              })),
            ),
          }),
        }),
      }),
    };
    const mockProviderService = { healthCheckAll: mockHealthCheckAll };

    const service = new (RoutingEngineService as unknown as new (
      db: unknown,
      ps: unknown,
    ) => RoutingEngineService)(mockDb, mockProviderService);

    const preSupplied = { anthropic: { status: 'up' } };
    await service.resolve('implement a function', undefined, preSupplied);

    expect(mockHealthCheckAll).not.toHaveBeenCalled();
  });
});
